Compare commits
3 Commits
agent-v2.0
...
agent-v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
440474290b | ||
|
|
6f783bfac8 | ||
|
|
f2ea415840 |
@@ -111,13 +111,13 @@ export class AnalyticsService {
|
||||
.createQueryBuilder('wipe')
|
||||
.leftJoinAndSelect('wipe.map', 'map')
|
||||
.select('map.id', 'map_id')
|
||||
.addSelect('map.name', 'map_name')
|
||||
.addSelect('map.display_name', 'map_name')
|
||||
.addSelect('COUNT(wipe.id)', 'usage_count')
|
||||
.where('wipe.license_id = :licenseId', { licenseId })
|
||||
.andWhere('wipe.started_at >= :cutoff', { cutoff })
|
||||
.andWhere('wipe.map_id IS NOT NULL')
|
||||
.groupBy('map.id')
|
||||
.addGroupBy('map.name')
|
||||
.addGroupBy('map.display_name')
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class AutoDoorsService {
|
||||
constructor(
|
||||
@InjectRepository(AutoDoorsConfig)
|
||||
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class AutoDoorsService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write AutoDoors.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/AutoDoors.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write AutoDoors.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/AutoDoors.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload AutoDoors plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload AutoDoors',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload AutoDoors');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class AutoDoorsService {
|
||||
/** Import AutoDoors.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read AutoDoors.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/AutoDoors.json',
|
||||
},
|
||||
30000,
|
||||
// Read AutoDoors.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/AutoDoors.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class AutoDoorsService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class BetterChatService {
|
||||
constructor(
|
||||
@InjectRepository(BetterChatConfig)
|
||||
private readonly repo: Repository<BetterChatConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class BetterChatService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write BetterChat.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/BetterChat.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write BetterChat.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/BetterChat.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload BetterChat plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload BetterChat',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterChat');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class BetterChatService {
|
||||
/** Import BetterChat.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read BetterChat.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/BetterChat.json',
|
||||
},
|
||||
30000,
|
||||
// Read BetterChat.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/BetterChat.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class BetterChatService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
|
||||
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class FurnaceSplitterService {
|
||||
constructor(
|
||||
@InjectRepository(FurnaceSplitterConfig)
|
||||
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class FurnaceSplitterService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write FurnaceSplitter.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/FurnaceSplitter.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write FurnaceSplitter.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/FurnaceSplitter.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload FurnaceSplitter plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload FurnaceSplitter',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload FurnaceSplitter');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class FurnaceSplitterService {
|
||||
/** Import FurnaceSplitter.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read FurnaceSplitter.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/FurnaceSplitter.json',
|
||||
},
|
||||
30000,
|
||||
// Read FurnaceSplitter.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/FurnaceSplitter.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class FurnaceSplitterService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class GatherService {
|
||||
constructor(
|
||||
@InjectRepository(GatherConfig)
|
||||
private readonly gatherRepo: Repository<GatherConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class GatherService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write GatherManager.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/GatherManager.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write GatherManager.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/GatherManager.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload GatherManager plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload GatherManager',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload GatherManager');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class GatherService {
|
||||
/** Import GatherManager.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read GatherManager.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/GatherManager.json',
|
||||
},
|
||||
30000,
|
||||
// Read GatherManager.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/GatherManager.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class GatherService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -142,4 +142,82 @@ export class InstancesService {
|
||||
if (!path || !dest) throw new BadRequestException('path and dest are required');
|
||||
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe an instance's game data via the agent's jailed wipe handler: stop →
|
||||
* delete files per wipe_type (map/blueprint/full) → restart. Long timeout
|
||||
* because the agent does all three steps before replying.
|
||||
*/
|
||||
async wipe(
|
||||
licenseId: string,
|
||||
instanceId: string,
|
||||
wipeType: 'map' | 'blueprint' | 'full',
|
||||
backup = true,
|
||||
): Promise<unknown> {
|
||||
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
|
||||
this.logger.log(`instance ${inst.agent_instance_id}: wipe (${wipeType})`);
|
||||
return this.nats.requestScoped(
|
||||
licenseId,
|
||||
subject,
|
||||
{ func: 'wipe', wipe_type: wipeType, backup },
|
||||
120_000,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// License-scoped convenience wrappers. Legacy single-server services
|
||||
// (servers/players/schedules/wipes/plugins + the 9 plugin-config modules)
|
||||
// predate the instance model and carry only a licenseId. These resolve the
|
||||
// license's primary instance, then dispatch to the agent — replacing the old
|
||||
// publishes to the now-defunct `cmd.server` subject.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** The license's primary (oldest) instance. Throws if none is connected. */
|
||||
async resolveDefaultInstance(licenseId: string): Promise<GameInstance> {
|
||||
const inst = await this.instanceRepo.findOne({
|
||||
where: { license_id: licenseId },
|
||||
order: { created_at: 'ASC' },
|
||||
});
|
||||
if (!inst) {
|
||||
throw new NotFoundException(
|
||||
'No game instance is connected for this license yet — install and start the host agent first.',
|
||||
);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
async lifecycleForLicense(licenseId: string, func: LifecycleFunc): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.lifecycle(licenseId, inst.id, func);
|
||||
}
|
||||
|
||||
async rconForLicense(licenseId: string, command: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.rcon(licenseId, inst.id, command);
|
||||
}
|
||||
|
||||
async writeFileForLicense(licenseId: string, path: string, content: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.writeFile(licenseId, inst.id, path, content);
|
||||
}
|
||||
|
||||
async readFileForLicense(licenseId: string, path: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.readFile(licenseId, inst.id, path);
|
||||
}
|
||||
|
||||
async deleteFileForLicense(licenseId: string, path: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.deleteFile(licenseId, inst.id, path);
|
||||
}
|
||||
|
||||
async wipeForLicense(
|
||||
licenseId: string,
|
||||
wipeType: 'map' | 'blueprint' | 'full',
|
||||
backup = true,
|
||||
): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.wipe(licenseId, inst.id, wipeType, backup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { KitsConfig } from '../../entities/kits-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
|
||||
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class KitsService {
|
||||
constructor(
|
||||
@InjectRepository(KitsConfig)
|
||||
private readonly kitsRepo: Repository<KitsConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class KitsService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write Kits.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/Kits.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write Kits.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/Kits.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload Kits plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload Kits',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload Kits');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class KitsService {
|
||||
/** Import Kits.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read Kits.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/Kits.json',
|
||||
},
|
||||
30000,
|
||||
// Read Kits.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/Kits.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class KitsService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||
@@ -15,7 +15,7 @@ export class LootService {
|
||||
constructor(
|
||||
@InjectRepository(LootProfile)
|
||||
private readonly lootRepo: Repository<LootProfile>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List profiles for a license (summaries — no JSONB) */
|
||||
@@ -114,37 +114,22 @@ export class LootService {
|
||||
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
|
||||
|
||||
try {
|
||||
// Write LootTables.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootTables.json',
|
||||
content: lootTablesJson,
|
||||
},
|
||||
30000,
|
||||
// Write LootTables.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/data/BetterLoot/LootTables.json',
|
||||
lootTablesJson,
|
||||
);
|
||||
|
||||
// Write LootGroups.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootGroups.json',
|
||||
content: lootGroupsJson,
|
||||
},
|
||||
30000,
|
||||
// Write LootGroups.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/data/BetterLoot/LootGroups.json',
|
||||
lootGroupsJson,
|
||||
);
|
||||
|
||||
// Reload BetterLoot plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload BetterLoot',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterLoot');
|
||||
|
||||
// Mark this profile as active, deactivate others
|
||||
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
export interface Player {
|
||||
@@ -23,7 +23,7 @@ export class PlayersService {
|
||||
private readonly actionRepo: Repository<PlayerAction>,
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly sessionRepo: Repository<PlayerSession>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -132,15 +132,26 @@ export class PlayersService {
|
||||
|
||||
await this.actionRepo.save(action);
|
||||
|
||||
// Forward kick, ban, and unban to the game server via NATS
|
||||
// Forward kick, ban, and unban to the game server via RCON
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
|
||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||
steam_id: dto.steam_id,
|
||||
reason: dto.reason,
|
||||
duration_minutes: dto.duration_minutes,
|
||||
});
|
||||
const rconCmd = this.buildRconCommand(dto);
|
||||
await this.instancesService.rconForLicense(licenseId, rconCmd);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private buildRconCommand(dto: PlayerActionDto): string {
|
||||
switch (dto.action_type) {
|
||||
case 'kick':
|
||||
return `kick ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
|
||||
case 'ban':
|
||||
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
|
||||
return `banid ${dto.steam_id} ${dto.reason ?? 'banned'} ${dto.duration_minutes ? dto.duration_minutes * 60 : 0}`;
|
||||
case 'unban':
|
||||
return `unban ${dto.steam_id}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, ServiceUnavailableException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
||||
import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
|
||||
interface UmodCacheEntry {
|
||||
data: unknown;
|
||||
@@ -20,7 +20,7 @@ export class PluginsService {
|
||||
constructor(
|
||||
@InjectRepository(PluginRegistry)
|
||||
private readonly pluginRegistryRepo: Repository<PluginRegistry>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
async getPlugins(licenseId: string): Promise<PluginRegistry[]> {
|
||||
@@ -43,30 +43,11 @@ export class PluginsService {
|
||||
throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`);
|
||||
}
|
||||
|
||||
const plugin = this.pluginRegistryRepo.create({
|
||||
license_id: licenseId,
|
||||
plugin_name: dto.plugin_name,
|
||||
umod_slug: dto.umod_slug,
|
||||
source: dto.source || 'manual',
|
||||
is_installed: true,
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
try {
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_install',
|
||||
plugin_name: dto.plugin_name,
|
||||
umod_slug: dto.umod_slug,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return saved;
|
||||
// One-click uMod install via agent is not yet implemented.
|
||||
// Fail fast — do not persist a DB record for a plugin that won't be deployed.
|
||||
throw new ServiceUnavailableException(
|
||||
'One-click uMod install is coming soon — download the .cs and use Upload for now.',
|
||||
);
|
||||
}
|
||||
|
||||
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
|
||||
@@ -80,11 +61,8 @@ export class PluginsService {
|
||||
|
||||
await this.pluginRegistryRepo.delete({ id: pluginId, license_id: licenseId });
|
||||
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, {
|
||||
action: 'unload',
|
||||
plugin_name: plugin.plugin_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(licenseId, `oxide.unload ${plugin.plugin_name}`);
|
||||
await this.instancesService.deleteFileForLicense(licenseId, `oxide/plugins/${plugin.plugin_name}.cs`);
|
||||
this.logger.log(`Plugin uninstall dispatched for ${plugin.plugin_name} on license ${licenseId}`);
|
||||
}
|
||||
|
||||
@@ -100,11 +78,7 @@ export class PluginsService {
|
||||
throw new NotFoundException(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name: plugin.plugin_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(licenseId, `oxide.reload ${plugin.plugin_name}`);
|
||||
this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`);
|
||||
|
||||
return { reloaded: true, plugin_name: plugin.plugin_name };
|
||||
@@ -215,19 +189,14 @@ export class PluginsService {
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
// Dispatch to companion agent via NATS
|
||||
// Deploy .cs file to server via host agent
|
||||
try {
|
||||
const content = file.buffer.toString('base64');
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_upload',
|
||||
filename: originalName,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
const content = file.buffer.toString('utf8');
|
||||
await this.instancesService.writeFileForLicense(licenseId, `oxide/plugins/${originalName}`, content);
|
||||
this.logger.log(`Plugin upload deployed: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, NATS delivery is best-effort
|
||||
this.logger.error(`File write failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, file delivery is best-effort
|
||||
}
|
||||
|
||||
return saved;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
|
||||
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class RaidableBasesService {
|
||||
constructor(
|
||||
@InjectRepository(RaidableBasesConfig)
|
||||
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class RaidableBasesService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write RaidableBases.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/RaidableBases.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write RaidableBases.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/RaidableBases.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload RaidableBases plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload RaidableBases',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload RaidableBases');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class RaidableBasesService {
|
||||
/** Import RaidableBases.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read RaidableBases.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/RaidableBases.json',
|
||||
},
|
||||
30000,
|
||||
// Read RaidableBases.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/RaidableBases.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class RaidableBasesService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
|
||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
||||
function nextCronDate(expr: string, after: Date): Date | null {
|
||||
@@ -61,7 +61,7 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
@InjectRepository(ScheduledTask)
|
||||
private taskRepository: Repository<ScheduledTask>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -160,21 +160,12 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
switch (task_type) {
|
||||
case 'restart':
|
||||
await this.natsService.sendServerCommand(license_id, 'restart', {
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
});
|
||||
await this.instancesService.lifecycleForLicense(license_id, 'restart');
|
||||
break;
|
||||
|
||||
case 'announcement': {
|
||||
const message = (task_config?.message as string) ?? 'Scheduled announcement';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command: `say ${message}`,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(license_id, `say ${message}`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -184,25 +175,13 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
|
||||
return;
|
||||
}
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(license_id, command);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plugin_reload': {
|
||||
const plugin_name = (task_config?.plugin_name as string) ?? '';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(license_id, `oxide.reload ${plugin_name}`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, ServiceUnavailableException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerConfig } from '../../entities/server-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { UpdateServerConfigDto } from './dto/update-config.dto';
|
||||
import { DeployServerDto } from './dto/deploy-server.dto';
|
||||
|
||||
@@ -17,6 +18,7 @@ export class ServersService {
|
||||
@InjectRepository(ServerConfig)
|
||||
private readonly configRepo: Repository<ServerConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -68,11 +70,11 @@ export class ServersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a console command to the server via NATS
|
||||
* Send a console command to the server via the host agent (RCON)
|
||||
*/
|
||||
async sendCommand(licenseId: string, command: string) {
|
||||
try {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
await this.instancesService.rconForLicense(licenseId, command);
|
||||
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
|
||||
@@ -82,42 +84,45 @@ export class ServersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server via NATS
|
||||
* Start the server via the host agent
|
||||
*/
|
||||
async startServer(licenseId: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'start');
|
||||
await this.instancesService.lifecycleForLicense(licenseId, 'start');
|
||||
return { message: 'Start command sent' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server via NATS
|
||||
* Stop the server via the host agent
|
||||
*/
|
||||
async stopServer(licenseId: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'stop');
|
||||
await this.instancesService.lifecycleForLicense(licenseId, 'stop');
|
||||
return { message: 'Stop command sent' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the server via NATS
|
||||
* Restart the server via the host agent
|
||||
*/
|
||||
async restartServer(licenseId: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'restart');
|
||||
await this.instancesService.lifecycleForLicense(licenseId, 'restart');
|
||||
return { message: 'Restart command sent' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy Rust server via companion agent
|
||||
* Deploy Rust server — not yet supported via host agent.
|
||||
* Install the server manually and point the host agent at it.
|
||||
*/
|
||||
async deployServer(licenseId: string, dto: DeployServerDto) {
|
||||
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
||||
return { message: 'Deployment started' };
|
||||
async deployServer(_licenseId: string, _dto: DeployServerDto) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Server deployment from the panel is coming soon — install the server and point the host agent at it for now.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Oxide/uMod via companion agent
|
||||
* Install Oxide/uMod — not yet supported via host agent.
|
||||
*/
|
||||
async installOxide(licenseId: string) {
|
||||
await this.natsService.sendOxideInstallCommand(licenseId);
|
||||
return { message: 'Oxide installation started' };
|
||||
async installOxide(_licenseId: string) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Oxide install from the panel is coming soon — install Oxide/uMod on the server for now.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -55,6 +55,13 @@ export class SetupService {
|
||||
if (dto.panel_api_key) {
|
||||
const encryptionKey = this.configService.get<string>('encryption.key', '');
|
||||
const keyBuffer = Buffer.from(encryptionKey, 'hex');
|
||||
// AES-256-GCM needs a 32-byte key. An unset/short ENCRYPTION_KEY would
|
||||
// otherwise crash createCipheriv with an opaque "Invalid key length" 500.
|
||||
if (keyBuffer.length !== 32) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Server encryption is not configured (ENCRYPTION_KEY must be 32 bytes / 64 hex chars). Contact the platform operator.',
|
||||
);
|
||||
}
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
@@ -82,9 +89,12 @@ export class SetupService {
|
||||
});
|
||||
|
||||
if (connection) {
|
||||
// For bare metal, mark as connected immediately (waiting for agent)
|
||||
if (connection.connection_type === 'bare_metal') {
|
||||
connection.connection_status = 'connected';
|
||||
// Bare-metal stays 'offline' until the agent's first heartbeat flips it
|
||||
// 'connected' (HostAgentConsumerService). Marking it connected here was a
|
||||
// false positive — the dashboard showed a live server before any agent
|
||||
// had checked in.
|
||||
if (connection.connection_type === 'bare_metal' && connection.connection_status !== 'connected') {
|
||||
connection.connection_status = 'offline';
|
||||
connection.updated_at = new Date();
|
||||
await this.connectionRepo.save(connection);
|
||||
}
|
||||
|
||||
@@ -57,11 +57,17 @@ export class StoreService {
|
||||
throw new NotFoundException('Module not found');
|
||||
}
|
||||
|
||||
// Beta: modules are granted free (no payment processing wired yet). Record
|
||||
// it honestly as a beta grant at $0 rather than a fake `txn_*` id that
|
||||
// implies a real charge occurred.
|
||||
this.logger.log(
|
||||
`Granting module ${moduleId} to license ${licenseId} free (Beta — no payment processing)`,
|
||||
);
|
||||
const purchase = this.purchaseRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
transaction_id: `txn_${Date.now()}`,
|
||||
amount_paid: parseFloat(module.price_usd.toString()),
|
||||
transaction_id: 'beta-free-grant',
|
||||
amount_paid: 0,
|
||||
});
|
||||
|
||||
return this.purchaseRepo.save(purchase);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class TeleportService {
|
||||
constructor(
|
||||
@InjectRepository(TeleportConfig)
|
||||
private readonly teleportRepo: Repository<TeleportConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class TeleportService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write NTeleportation.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write NTeleportation.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/NTeleportation.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload NTeleportation plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload NTeleportation',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload NTeleportation');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class TeleportService {
|
||||
/** Import NTeleportation.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read NTeleportation.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
},
|
||||
30000,
|
||||
// Read NTeleportation.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/NTeleportation.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class TeleportService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
|
||||
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class TimedExecuteService {
|
||||
constructor(
|
||||
@InjectRepository(TimedExecuteConfig)
|
||||
private readonly repo: Repository<TimedExecuteConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class TimedExecuteService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write TimedExecute.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/TimedExecute.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write TimedExecute.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/TimedExecute.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload TimedExecute plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload TimedExecute',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload TimedExecute');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class TimedExecuteService {
|
||||
/** Import TimedExecute.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read TimedExecute.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/TimedExecute.json',
|
||||
},
|
||||
30000,
|
||||
// Read TimedExecute.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/TimedExecute.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class TimedExecuteService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StoreConfig } from '../../entities/store-config.entity';
|
||||
@@ -224,23 +224,13 @@ export class WebstoreService {
|
||||
throw new NotFoundException('Item not found');
|
||||
}
|
||||
|
||||
const transaction = this.transactionRepo.create({
|
||||
license_id: license.id,
|
||||
item_id: item.id,
|
||||
steam_id: dto.steam_id,
|
||||
player_name: dto.player_name,
|
||||
paypal_order_id: `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
amount: parseFloat(item.price.toString()),
|
||||
currency: 'USD', // Would get from config
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await this.transactionRepo.save(transaction);
|
||||
|
||||
// Return mock PayPal approval URL
|
||||
return {
|
||||
order_id: transaction.paypal_order_id,
|
||||
approval_url: `https://www.sandbox.paypal.com/checkoutnow?token=${transaction.paypal_order_id}`,
|
||||
};
|
||||
// Beta: real PayPal/Stripe processing is not wired yet. Refuse honestly
|
||||
// instead of writing a pending transaction and handing the player a fake
|
||||
// order token that resolves to nowhere. (item lookup above still validates
|
||||
// the request so the storefront UI can show the catalogue.)
|
||||
void item;
|
||||
throw new ServiceUnavailableException(
|
||||
'Storefront checkout is not available yet — payment processing is coming soon.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CreateProfileDto } from './dto/create-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
|
||||
@Injectable()
|
||||
export class WipesService {
|
||||
@@ -21,7 +21,7 @@ export class WipesService {
|
||||
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
|
||||
@InjectRepository(WipeHistory)
|
||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
||||
@@ -107,13 +107,7 @@ export class WipesService {
|
||||
|
||||
const saved = await this.wipeHistoryRepo.save(history);
|
||||
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.wipe`, {
|
||||
wipe_history_id: saved.id,
|
||||
wipe_type: dto.wipe_type,
|
||||
wipe_profile_id: dto.wipe_profile_id ?? null,
|
||||
trigger_type: 'manual',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
|
||||
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
|
||||
|
||||
return { wipe_history_id: saved.id };
|
||||
|
||||
2
corrosion-host-agent/Cargo.lock
generated
2
corrosion-host-agent/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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)"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,3 +17,4 @@ pub mod supervisor;
|
||||
pub mod telemetry;
|
||||
pub mod update;
|
||||
pub mod version;
|
||||
pub mod wipe;
|
||||
|
||||
412
corrosion-host-agent/src/wipe.rs
Normal file
412
corrosion-host-agent/src/wipe.rs
Normal 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(())
|
||||
}
|
||||
298
corrosion-host-agent/tests/wipe.rs
Normal file
298
corrosion-host-agent/tests/wipe.rs
Normal 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
|
||||
}
|
||||
100
docs/PRICING.md
100
docs/PRICING.md
@@ -1,27 +1,95 @@
|
||||
# Pricing
|
||||
|
||||
> This document mirrors the live pricing page at corrosionmgmt.com/pricing.
|
||||
|
||||
---
|
||||
|
||||
## Base License — $50 (Launch Price)
|
||||
## Hobby — $9.99/month
|
||||
|
||||
One server. Lifetime access.
|
||||
1–5 game server instances · non-commercial use only.
|
||||
|
||||
Includes:
|
||||
|
||||
* Full control plane
|
||||
* Auto-Wiper
|
||||
* Plugin management
|
||||
* Public site
|
||||
* RBAC
|
||||
|
||||
## Webstore Add-On — $10/month
|
||||
|
||||
Integrated monetization platform.
|
||||
|
||||
## Modules — $9.99+
|
||||
|
||||
Optional feature expansions.
|
||||
- Up to 5 game server instances
|
||||
- Non-commercial servers only
|
||||
- Auto-wiper with rollback
|
||||
- Plugin management (Rust uMod/Oxide)
|
||||
- File manager + real-time console
|
||||
- Scheduled tasks
|
||||
- Public server page
|
||||
- Community support
|
||||
|
||||
---
|
||||
|
||||
Simple. Transparent. No hidden tiers.
|
||||
## Community — $19.99/month
|
||||
|
||||
6–10 game server instances · non-commercial use only.
|
||||
|
||||
Includes:
|
||||
|
||||
- Up to 10 game server instances
|
||||
- Non-commercial servers only
|
||||
- Auto-wiper with rollback
|
||||
- Plugin management (Rust uMod/Oxide)
|
||||
- File manager + real-time console
|
||||
- Scheduled tasks
|
||||
- Public server page
|
||||
- Community support
|
||||
|
||||
---
|
||||
|
||||
## Operator — $99.99/month _(Most popular)_
|
||||
|
||||
Commercial use permitted, or up to 50 servers.
|
||||
|
||||
Includes:
|
||||
|
||||
- Up to 50 game server instances
|
||||
- Commercial use permitted
|
||||
- All games: Rust, Dune: Awakening, Soulmask, Conan Exiles
|
||||
- Auto-wiper with rollback
|
||||
- Plugin + mod management
|
||||
- File manager + real-time console
|
||||
- Scheduled tasks + maintenance windows
|
||||
- Player management + RBAC team access
|
||||
- Public server page + storefront
|
||||
- Community support + priority bug triage
|
||||
|
||||
---
|
||||
|
||||
## Network — Custom pricing
|
||||
|
||||
50+ servers · hosting partners and fleets. Contact support@corrosionmgmt.com for pricing.
|
||||
|
||||
Includes:
|
||||
|
||||
- 50 servers base included
|
||||
- Fleet Blocks: +$49.99/mo per additional 50 servers
|
||||
- Commercial use permitted
|
||||
- All games + multi-game hosts
|
||||
- Full Operator feature set
|
||||
- Fleet-level management
|
||||
- Priority bug triage for platform issues
|
||||
- Community support
|
||||
|
||||
---
|
||||
|
||||
## Fleet Block Add-On — +$49.99/month per 50 servers
|
||||
|
||||
Stack as many Fleet Blocks as your Network plan operation requires.
|
||||
|
||||
---
|
||||
|
||||
## Direct 1:1 Support — $125/hour (prepaid 1-hour blocks)
|
||||
|
||||
Available to any customer. Billed time with a human — not a support tier. Community support (docs, forum, diagnostics, structured bug reports) is included with every plan at no extra charge.
|
||||
|
||||
---
|
||||
|
||||
## Commercial Use Definition
|
||||
|
||||
Commercial use includes monetized communities, paid access, VIP slots, donations, sponsorship-supported servers, hosting providers, or managing servers for others. Hobby and Community plans are non-commercial only. Operator and Network plans permit commercial use.
|
||||
|
||||
---
|
||||
|
||||
Simple. Transparent. No per-seat charges. No hidden tiers.
|
||||
|
||||
@@ -277,17 +277,6 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="top__search">
|
||||
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input placeholder="Search servers, players, configs…" readonly />
|
||||
<span class="top__kbd">
|
||||
<kbd class="cc-kbd">⌘</kbd><kbd class="cc-kbd">K</kbd>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="top__actions">
|
||||
<IconButton
|
||||
@@ -296,7 +285,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||
@click="toggleTheme"
|
||||
/>
|
||||
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
|
||||
<Button size="sm" icon="rocket">Deploy server</Button>
|
||||
<Button size="sm" icon="rocket" @click="router.push('/server')">Deploy server</Button>
|
||||
<Avatar
|
||||
:name="userName"
|
||||
:size="30"
|
||||
|
||||
@@ -66,8 +66,7 @@ const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||
</div>
|
||||
<div class="footer__col">
|
||||
<h5>Company</h5>
|
||||
<RouterLink :to="{ name: 'landing' }">About</RouterLink>
|
||||
<RouterLink :to="{ name: 'roadmap' }">Changelog</RouterLink>
|
||||
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||
<a href="mailto:support@corrosionmgmt.com">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,12 +51,12 @@ export function useWebSocket() {
|
||||
|
||||
function connect() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
console.log('[WebSocket] Not authenticated, skipping connection')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Not authenticated, skipping connection')
|
||||
return
|
||||
}
|
||||
|
||||
if (isConnecting.value || isConnected.value) {
|
||||
console.log('[WebSocket] Already connecting or connected')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting or connected')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,12 +65,12 @@ export function useWebSocket() {
|
||||
error.value = null
|
||||
|
||||
const url = getWebSocketUrl()
|
||||
console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***'))
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***'))
|
||||
|
||||
ws.value = new WebSocket(url)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('[WebSocket] Connected')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connected')
|
||||
isConnected.value = true
|
||||
isConnecting.value = false
|
||||
reconnectAttempts.value = 0
|
||||
@@ -80,7 +80,7 @@ export function useWebSocket() {
|
||||
ws.value.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data)
|
||||
console.log('[WebSocket] Message received:', message)
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Message received:', message)
|
||||
|
||||
// Broadcast to all handlers
|
||||
messageHandlers.forEach(handler => {
|
||||
@@ -102,7 +102,7 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
ws.value.onclose = (event) => {
|
||||
console.log('[WebSocket] Closed:', event.code, event.reason)
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Closed:', event.code, event.reason)
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
|
||||
@@ -132,7 +132,7 @@ export function useWebSocket() {
|
||||
30000 // Max 30 seconds
|
||||
)
|
||||
|
||||
console.log(
|
||||
if (import.meta.env.DEV) console.log(
|
||||
`[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts})`
|
||||
)
|
||||
|
||||
@@ -148,7 +148,7 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
if (ws.value) {
|
||||
console.log('[WebSocket] Disconnecting')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Disconnecting')
|
||||
ws.value.close(1000, 'Client disconnect')
|
||||
ws.value = null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
@@ -30,6 +31,7 @@ interface AlertHistoryEntry {
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
const config = ref<AlertConfig>({
|
||||
population_drop_enabled: false,
|
||||
population_drop_threshold_percent: 50,
|
||||
@@ -60,9 +62,9 @@ async function saveConfig() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await api.put('/alerts/config', config.value)
|
||||
alert('Alert configuration saved')
|
||||
toast.success('Alert configuration saved')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to save configuration')
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save configuration')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ const renderCharts = () => {
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', {
|
||||
data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit'
|
||||
@@ -116,7 +116,7 @@ const renderCharts = () => {
|
||||
{
|
||||
name: 'Players',
|
||||
type: 'line',
|
||||
data: timeseries.value.player_count,
|
||||
data: timeseries.value.player_count ?? [],
|
||||
smooth: true,
|
||||
lineStyle: { color: accent, width: 2 },
|
||||
areaStyle: {
|
||||
@@ -160,7 +160,7 @@ const renderCharts = () => {
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', {
|
||||
data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit'
|
||||
@@ -191,7 +191,7 @@ const renderCharts = () => {
|
||||
name: 'FPS',
|
||||
type: 'line',
|
||||
yAxisIndex: 0,
|
||||
data: timeseries.value.fps,
|
||||
data: timeseries.value.fps ?? [],
|
||||
smooth: true,
|
||||
lineStyle: { color: '#10b981', width: 2 },
|
||||
itemStyle: { color: '#10b981' }
|
||||
@@ -200,7 +200,7 @@ const renderCharts = () => {
|
||||
name: 'Entities',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: timeseries.value.entity_count,
|
||||
data: timeseries.value.entity_count ?? [],
|
||||
smooth: true,
|
||||
lineStyle: { color: '#6366f1', width: 2 },
|
||||
itemStyle: { color: '#6366f1' }
|
||||
@@ -287,7 +287,7 @@ onMounted(() => {
|
||||
label="Unique players"
|
||||
:value="summary.unique_players ?? '—'"
|
||||
icon="bar-chart-3"
|
||||
note="Phase 2.2"
|
||||
note="Coming soon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -302,9 +302,9 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Player Retention placeholder -->
|
||||
<Panel eyebrow="Coming in phase 2" title="Player retention">
|
||||
<Panel eyebrow="Coming soon" title="Player retention">
|
||||
<template #title-append>
|
||||
<Badge tone="neutral">Phase 2</Badge>
|
||||
<Badge tone="neutral">Coming soon</Badge>
|
||||
</template>
|
||||
<div class="analytics-view__retention-grid">
|
||||
<div class="analytics-view__retention-cell">
|
||||
@@ -324,7 +324,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<p class="analytics-view__retention-footer">
|
||||
Player retention analytics will be available in phase 2.
|
||||
Player retention analytics are coming soon.
|
||||
</p>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAutoDoorsStore } from '@/stores/autodoors'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const store = useAutoDoorsStore()
|
||||
const { activeGame } = useThemeGame()
|
||||
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showImportModal = ref(false)
|
||||
@@ -159,6 +163,16 @@ function getBool(path: string, def: boolean): boolean {
|
||||
|
||||
<template>
|
||||
<div class="adv">
|
||||
<!-- uMod-only guard: AutoDoors is an Oxide/uMod plugin -->
|
||||
<Panel v-if="gameProfile.mods !== 'umod'">
|
||||
<EmptyState
|
||||
icon="door-open"
|
||||
title="Rust / uMod only"
|
||||
description="Auto Doors is only available for Rust (uMod/Oxide) servers."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<template v-else>
|
||||
<!-- Page head -->
|
||||
<div class="adv__head">
|
||||
<div class="adv__head-id">
|
||||
@@ -504,6 +518,7 @@ function getBool(path: string, def: boolean): boolean {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useBetterChatStore } from '@/stores/betterchat'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
@@ -13,6 +15,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const store = useBetterChatStore()
|
||||
const { activeGame } = useThemeGame()
|
||||
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
|
||||
|
||||
const activeTab = ref<string>('groups')
|
||||
const showCreateModal = ref(false)
|
||||
@@ -276,6 +280,16 @@ const editGroupFormatConsole = computed<string>({
|
||||
|
||||
<template>
|
||||
<div class="bch">
|
||||
<!-- uMod-only guard: BetterChat is an Oxide/uMod plugin -->
|
||||
<Panel v-if="gameProfile.mods !== 'umod'">
|
||||
<EmptyState
|
||||
icon="message-square"
|
||||
title="Rust / uMod only"
|
||||
description="Better Chat is only available for Rust (uMod/Oxide) servers."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<template v-else>
|
||||
<!-- Page head -->
|
||||
<div class="bch__head">
|
||||
<div class="bch__head-id">
|
||||
@@ -696,6 +710,7 @@ const editGroupFormatConsole = computed<string>({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import type { ChatMessage } from '@/types'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
@@ -14,6 +15,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
const { activeGame } = useThemeGame()
|
||||
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isLoading = ref(false)
|
||||
@@ -122,7 +125,7 @@ onMounted(() => {
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search messages, players, or Steam IDs…"
|
||||
:placeholder="`Search messages, players, or ${playerIdLabel}s…`"
|
||||
size="sm"
|
||||
style="max-width: 340px;"
|
||||
/>
|
||||
|
||||
@@ -383,7 +383,7 @@ function navServer() { router.push('/server') }
|
||||
v-model="consoleInput"
|
||||
:mono="true"
|
||||
size="sm"
|
||||
placeholder="say, kick, ban, oxide.reload …"
|
||||
:placeholder="profile.mods === 'umod' ? 'say, kick, ban, oxide.reload …' : 'say, kick, ban …'"
|
||||
:disabled="!isConnected"
|
||||
style="flex: 1"
|
||||
@keydown.enter="sendConsoleCommand"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const store = useFurnaceSplitterStore()
|
||||
const { activeGame } = useThemeGame()
|
||||
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showImportModal = ref(false)
|
||||
@@ -116,6 +120,16 @@ function getBool(path: string, def: boolean): boolean {
|
||||
|
||||
<template>
|
||||
<div class="fsv">
|
||||
<!-- uMod-only guard: Furnace Splitter is an Oxide/uMod plugin -->
|
||||
<Panel v-if="gameProfile.mods !== 'umod'">
|
||||
<EmptyState
|
||||
icon="flame"
|
||||
title="Rust / uMod only"
|
||||
description="Furnace Splitter is only available for Rust (uMod/Oxide) servers."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<template v-else>
|
||||
<!-- Page head -->
|
||||
<div class="fsv__head">
|
||||
<div class="fsv__head-id">
|
||||
@@ -326,6 +340,7 @@ function getBool(path: string, def: boolean): boolean {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { safeFileSize, safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
@@ -20,6 +21,7 @@ interface ExportRecord {
|
||||
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
const exports = ref<ExportRecord[]>([])
|
||||
const isExporting = ref(false)
|
||||
const isImporting = ref(false)
|
||||
@@ -37,7 +39,7 @@ async function createExport() {
|
||||
isExporting.value = true
|
||||
try {
|
||||
const result = await api.post<{ export_id: string }>('/migration/export', { export_type: exportType.value })
|
||||
alert(`Export created: ${result.export_id}`)
|
||||
toast.success(`Export created: ${result.export_id}`)
|
||||
await fetchExports()
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
@@ -15,6 +16,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
const server = useServerStore()
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
const { activeGame } = useThemeGame()
|
||||
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
|
||||
|
||||
interface Player {
|
||||
steam_id: string
|
||||
@@ -166,7 +169,7 @@ onMounted(() => {
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search by name or Steam ID…"
|
||||
:placeholder="`Search by name or ${playerIdLabel}…`"
|
||||
size="sm"
|
||||
:mono="false"
|
||||
style="max-width: 320px;"
|
||||
@@ -197,7 +200,7 @@ onMounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Steam ID</th>
|
||||
<th>{{ playerIdLabel }}</th>
|
||||
<th>Status</th>
|
||||
<th>Session</th>
|
||||
<th>Playtime</th>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
@@ -22,6 +23,7 @@ interface ScheduledTask {
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
const tasks = ref<ScheduledTask[]>([])
|
||||
const isLoading = ref(false)
|
||||
const showModal = ref(false)
|
||||
@@ -93,7 +95,7 @@ async function saveTask() {
|
||||
showModal.value = false
|
||||
await fetchTasks()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to save task')
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save task')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ const showCreds = ref(false)
|
||||
const tomlCopied = ref(false)
|
||||
|
||||
const deployForm = ref<DeploymentConfig>({
|
||||
server_name: 'My Rust Server',
|
||||
server_name: '',
|
||||
max_players: 100,
|
||||
world_size: 4000,
|
||||
seed: Math.floor(Math.random() * 2147483647),
|
||||
@@ -465,7 +465,7 @@ onMounted(async () => {
|
||||
}
|
||||
if (msg.type === 'event' && msg.event === 'oxide_status') {
|
||||
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
|
||||
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
|
||||
if (msg.data && ((msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed')) {
|
||||
isInstallingOxide.value = false
|
||||
}
|
||||
}
|
||||
@@ -935,7 +935,7 @@ onMounted(async () => {
|
||||
|
||||
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
|
||||
<Panel
|
||||
v-if="profile.accent === 'conan'"
|
||||
v-if="activeGame === 'conan'"
|
||||
title="Conan Exiles concepts"
|
||||
subtitle="Key admin mechanics for Conan Exiles servers"
|
||||
>
|
||||
|
||||
@@ -166,7 +166,7 @@ onMounted(() => {
|
||||
<Input
|
||||
v-model="config.store_name"
|
||||
label="Store name"
|
||||
placeholder="My Rust Server Store"
|
||||
placeholder="My server store"
|
||||
:required="true"
|
||||
hint="Displayed to players on the store page"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
import type { StoreCategory, StoreItem } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
@@ -14,6 +16,8 @@ import Select from '@/components/ds/forms/Select.vue'
|
||||
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||
|
||||
const api = useApi()
|
||||
const { activeGame } = useThemeGame()
|
||||
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
|
||||
|
||||
const tab = ref<'categories' | 'items'>('categories')
|
||||
const isLoading = ref(false)
|
||||
@@ -46,12 +50,19 @@ const itemForm = ref({
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const itemTypes = [
|
||||
const itemTypesUmod = [
|
||||
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
|
||||
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
|
||||
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
|
||||
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
|
||||
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
|
||||
]
|
||||
const itemTypesGeneric = [
|
||||
{ value: 'kit', label: 'Kit', example: 'givecontent {steam_id} item_id 1' },
|
||||
{ value: 'rank', label: 'Rank', example: 'setrank {steam_id} vip' },
|
||||
{ value: 'currency', label: 'Currency', example: 'addcurrency {steam_id} 1000' },
|
||||
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
|
||||
]
|
||||
const itemTypes = computed(() => gameProfile.value.mods === 'umod' ? itemTypesUmod : itemTypesGeneric)
|
||||
|
||||
const tabItems = computed(() => [
|
||||
{ value: 'categories', label: 'Categories', count: categories.value.length },
|
||||
@@ -251,7 +262,7 @@ function getCategoryName(categoryId: string | null): string {
|
||||
}
|
||||
|
||||
const selectedTypeExample = computed(() => {
|
||||
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
|
||||
const type = itemTypes.value.find(t => t.value === itemForm.value.item_type)
|
||||
return type?.example ?? ''
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useTimedExecuteStore } from '@/stores/timedexecute'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
@@ -12,6 +14,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const store = useTimedExecuteStore()
|
||||
const { activeGame } = useThemeGame()
|
||||
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
|
||||
|
||||
const activeTab = ref<string>('timed')
|
||||
const showCreateModal = ref(false)
|
||||
@@ -360,7 +364,7 @@ const importConfigNameModel = computed<string>({
|
||||
<span class="te__presets-label">Quick add:</span>
|
||||
<button class="te__preset" @click="addPresetTimer('server.save', 300)">server.save (5 min)</button>
|
||||
<button class="te__preset" @click="addPresetTimer('say Server restart warning!', 3600)">Restart warning (1 h)</button>
|
||||
<button class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
|
||||
<button v-if="gameProfile.mods === 'umod'" class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
import type { WipeProfile } from '@/types'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
@@ -13,6 +15,8 @@ import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
const toast = useToastStore()
|
||||
const { activeGame } = useThemeGame()
|
||||
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
|
||||
|
||||
const expandedId = ref<string | null>(null)
|
||||
const showModal = ref(false)
|
||||
@@ -242,7 +246,7 @@ onMounted(() => {
|
||||
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="detail-kv">
|
||||
<div v-if="gameProfile.mods === 'umod'" class="detail-kv">
|
||||
<span class="detail-k">Verify plugins loaded</span>
|
||||
<Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
|
||||
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
|
||||
@@ -359,6 +363,7 @@ onMounted(() => {
|
||||
label="Verify correct map"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="gameProfile.mods === 'umod'"
|
||||
v-model="form.post_wipe_config.verify_plugins_loaded"
|
||||
label="Verify plugins loaded"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
@@ -18,6 +20,8 @@ const wipeStore = useWipeStore()
|
||||
const server = useServerStore()
|
||||
const toast = useToastStore()
|
||||
const api = useApi()
|
||||
const { activeGame } = useThemeGame()
|
||||
const profile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
|
||||
|
||||
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
|
||||
const selectedProfileId = ref<string>('')
|
||||
@@ -71,11 +75,18 @@ async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const WIPE_TYPE_OPTIONS = [
|
||||
const WIPE_TYPE_OPTIONS_BASE = [
|
||||
{ value: 'map', label: 'Map' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
]
|
||||
const WIPE_TYPE_OPTIONS_RUST = [
|
||||
{ value: 'map', label: 'Map' },
|
||||
{ value: 'blueprint', label: 'Blueprint' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
]
|
||||
const wipeTypeOptions = computed(() =>
|
||||
profile.value.mods === 'umod' ? WIPE_TYPE_OPTIONS_RUST : WIPE_TYPE_OPTIONS_BASE
|
||||
)
|
||||
|
||||
function profileOptions() {
|
||||
const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }]
|
||||
@@ -148,7 +159,7 @@ onMounted(async () => {
|
||||
<div class="cc-field__label">Wipe type</div>
|
||||
<div class="type-seg">
|
||||
<button
|
||||
v-for="opt in WIPE_TYPE_OPTIONS"
|
||||
v-for="opt in wipeTypeOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="type-seg__btn"
|
||||
|
||||
@@ -107,7 +107,7 @@ async function completeSetup() {
|
||||
<div v-if="step === 1" class="setup-card">
|
||||
<div class="setup-card__head">
|
||||
<h1 class="setup-card__title">Configure your server</h1>
|
||||
<p class="setup-card__sub">Connect your Rust server to Corrosion.</p>
|
||||
<p class="setup-card__sub">Connect your game server to Corrosion.</p>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" tone="danger">{{ error }}</Alert>
|
||||
@@ -117,7 +117,7 @@ async function completeSetup() {
|
||||
v-model="serverForm.server_name"
|
||||
label="Server name"
|
||||
type="text"
|
||||
placeholder="My Rust Server"
|
||||
placeholder="My game server"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EarlyAccess signup page.
|
||||
*
|
||||
* Backend endpoint: POST /api/early-access
|
||||
* The early_access_signups entity exists but no NestJS controller/module exposes it yet.
|
||||
* TODO: Create backend-nest/src/modules/early-access/ with a @Public() POST /early-access
|
||||
* controller that accepts { email, name?, game_interest? } and writes to early_access_signups.
|
||||
* The server_count column on the entity is varchar(10) — map game_interest to it or add a
|
||||
* migration adding a game_interest column.
|
||||
* Backend endpoint: POST /api/early-access — live and functional.
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
@@ -130,12 +124,12 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
|
||||
<b>Direct feedback channel</b>
|
||||
<p>Early access operators have a direct line for platform bug reports and feature input.</p>
|
||||
<p>Early access operators have a direct feedback channel for platform bug reports and feature input.</p>
|
||||
</div>
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="box" :size="16" /></div>
|
||||
<b>Rust-first</b>
|
||||
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p>
|
||||
<b>Multi-game</b>
|
||||
<p>Rust is fully operational today. Dune: Awakening, Conan Exiles, and Soulmask support is in active development.</p>
|
||||
</div>
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="users" :size="16" /></div>
|
||||
|
||||
@@ -69,7 +69,7 @@ const groups: FaqGroup[] = [
|
||||
{
|
||||
question: 'Does Corrosion replace AMP or Pterodactyl?',
|
||||
answer:
|
||||
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is available in the panel.',
|
||||
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is in active development.',
|
||||
},
|
||||
{
|
||||
question: 'What happens if Corrosion goes offline?',
|
||||
|
||||
@@ -197,6 +197,14 @@ const mockActiveGame = activeGame
|
||||
</span>
|
||||
<span class="st"><b />online</span>
|
||||
</div>
|
||||
<div class="mock__row">
|
||||
<span class="g"><Icon name="drama" :size="13" /></span>
|
||||
<span class="nm">
|
||||
Ritual Cluster · PvE
|
||||
<small>soul-host · soulmask</small>
|
||||
</span>
|
||||
<span class="st"><b />online</span>
|
||||
</div>
|
||||
<div class="mock__row">
|
||||
<span class="g"><Icon name="swords" :size="13" /></span>
|
||||
<span class="nm">
|
||||
@@ -244,7 +252,7 @@ const mockActiveGame = activeGame
|
||||
</div>
|
||||
<div class="pain__item">
|
||||
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||
Juggling Discord bots & cron tasks
|
||||
Juggling community bots & cron tasks
|
||||
</div>
|
||||
<div class="pain__item">
|
||||
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||
@@ -442,7 +450,7 @@ const mockActiveGame = activeGame
|
||||
</div>
|
||||
<div class="feat">
|
||||
<span class="feat__ic"><Icon name="bell" :size="16" /></span>
|
||||
<b>Discord / status announcements</b>
|
||||
<b>Webhook / status announcements</b>
|
||||
</div>
|
||||
<div class="feat">
|
||||
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span>
|
||||
@@ -577,7 +585,7 @@ const mockActiveGame = activeGame
|
||||
<div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div>
|
||||
<div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div>
|
||||
<div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div>
|
||||
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore</div>
|
||||
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore (coming soon)</div>
|
||||
</div>
|
||||
<p
|
||||
class="closing reveal"
|
||||
@@ -620,9 +628,9 @@ const mockActiveGame = activeGame
|
||||
<div class="plan">
|
||||
<div class="plan__tag" />
|
||||
<div class="plan__name">Network</div>
|
||||
<div class="plan__price">$99.99<small>/mo</small></div>
|
||||
<div class="plan__price">Custom</div>
|
||||
<div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div>
|
||||
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
|
||||
<a class="btn btn--ghost" href="mailto:support@corrosionmgmt.com">Contact us</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fleetblock reveal">
|
||||
|
||||
@@ -41,6 +41,7 @@ interface Plan {
|
||||
featured: boolean
|
||||
cta: string
|
||||
ctaVariant: 'primary' | 'ghost'
|
||||
ctaHref?: string
|
||||
features: PlanFeature[]
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ const plans: Plan[] = [
|
||||
{ text: 'Up to 5 game server instances' },
|
||||
{ text: 'Non-commercial servers only' },
|
||||
{ text: 'Auto-wiper with rollback' },
|
||||
{ text: 'Plugin management (Rust)' },
|
||||
{ text: 'Plugin management (Rust uMod/Oxide)' },
|
||||
{ text: 'File manager + real-time console' },
|
||||
{ text: 'Scheduled tasks' },
|
||||
{ text: 'Public server page' },
|
||||
@@ -78,7 +79,7 @@ const plans: Plan[] = [
|
||||
{ text: 'Up to 10 game server instances' },
|
||||
{ text: 'Non-commercial servers only' },
|
||||
{ text: 'Auto-wiper with rollback' },
|
||||
{ text: 'Plugin management (Rust)' },
|
||||
{ text: 'Plugin management (Rust uMod/Oxide)' },
|
||||
{ text: 'File manager + real-time console' },
|
||||
{ text: 'Scheduled tasks' },
|
||||
{ text: 'Public server page' },
|
||||
@@ -109,13 +110,14 @@ const plans: Plan[] = [
|
||||
},
|
||||
{
|
||||
name: 'Network',
|
||||
price: '$99.99',
|
||||
period: '/mo',
|
||||
price: 'Custom',
|
||||
period: '',
|
||||
scope: '50+ servers · hosting partners + fleets',
|
||||
tag: '',
|
||||
featured: false,
|
||||
cta: 'Join early access',
|
||||
cta: 'Contact us',
|
||||
ctaVariant: 'ghost',
|
||||
ctaHref: 'mailto:support@corrosionmgmt.com',
|
||||
features: [
|
||||
{ text: '50 servers base included' },
|
||||
{ text: 'Fleet Blocks: +$49.99/mo per 50 servers' },
|
||||
@@ -176,7 +178,16 @@ const plans: Plan[] = [
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<a
|
||||
v-if="plan.ctaHref"
|
||||
class="btn"
|
||||
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
|
||||
:href="plan.ctaHref"
|
||||
>
|
||||
{{ plan.cta }}
|
||||
</a>
|
||||
<RouterLink
|
||||
v-else
|
||||
class="btn"
|
||||
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
|
||||
:to="{ name: 'early-access' }"
|
||||
|
||||
@@ -25,7 +25,7 @@ const groups: RoadmapGroup[] = [
|
||||
status: 'shipped',
|
||||
label: 'Phase 1 — Foundation',
|
||||
description:
|
||||
'The core control plane is live. Rust server operators can install the agent, connect their server, and manage it entirely from the panel.',
|
||||
'The core control plane is live. Game server operators can install the agent, connect their server, and manage it entirely from the panel.',
|
||||
items: [
|
||||
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
|
||||
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
@@ -25,6 +26,7 @@ interface ServerInfo {
|
||||
|
||||
const route = useRoute()
|
||||
const subdomain = route.params.subdomain as string
|
||||
const toast = useToastStore()
|
||||
const serverInfo = ref<ServerInfo | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -48,7 +50,7 @@ async function fetchServerInfo() {
|
||||
function copyConnectUrl() {
|
||||
if (serverInfo.value?.connect_url) {
|
||||
navigator.clipboard.writeText(serverInfo.value.connect_url)
|
||||
alert('Connect URL copied to clipboard')
|
||||
toast.success('Connect URL copied to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,14 +144,14 @@ onMounted(() => {
|
||||
|
||||
<!-- Discord -->
|
||||
<Panel v-if="serverInfo.discord_invite" title="Community">
|
||||
<Alert tone="info" title="Join our Discord">
|
||||
<Alert tone="info" title="Join our community">
|
||||
<template #actions>
|
||||
<a
|
||||
:href="serverInfo.discord_invite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button size="sm" variant="secondary" icon="external-link">Join Discord</Button>
|
||||
<Button size="sm" variant="secondary" icon="external-link">Join community</Button>
|
||||
</a>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types'
|
||||
import { safeCurrency } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
@@ -15,6 +16,7 @@ import Logo from '@/components/ds/brand/Logo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const subdomain = computed(() => route.params.subdomain as string)
|
||||
const toast = useToastStore()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const storeInfo = ref<PublicStoreInfo | null>(null)
|
||||
@@ -125,6 +127,12 @@ async function confirmPurchase() {
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.status === 503) {
|
||||
closePurchaseModal()
|
||||
toast.info("Checkout is coming soon — payments aren't enabled yet.")
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Purchase failed' }))
|
||||
throw new Error(error.message || 'Purchase failed')
|
||||
@@ -137,8 +145,7 @@ async function confirmPurchase() {
|
||||
|
||||
// Close modal and show success message
|
||||
closePurchaseModal()
|
||||
// TODO: Show toast notification
|
||||
alert('PayPal window opened. Complete your payment there. Your items will be delivered automatically after payment.')
|
||||
toast.success('PayPal window opened. Complete your payment there. Your items will be delivered automatically after payment.')
|
||||
} catch (error: any) {
|
||||
purchaseError.value = error.message || 'Purchase failed. Please try again.'
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user