Compare commits
21 Commits
agent-v2.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9c7a8a97 | ||
|
|
907cfcb428 | ||
|
|
b1961df18e | ||
|
|
cfdec62a1d | ||
|
|
e510f8b005 | ||
|
|
cf1f1dea9a | ||
|
|
2e72850b97 | ||
|
|
9f9785fc09 | ||
|
|
142ba21113 | ||
|
|
04e664045b | ||
|
|
cef95540fc | ||
|
|
7f2207bc28 | ||
|
|
57858a1e1c | ||
|
|
5b323137e0 | ||
|
|
4d455918f5 | ||
|
|
a1768bdd2a | ||
|
|
0effaaf86c | ||
|
|
55c9893131 | ||
|
|
62bc9cd2a3 | ||
|
|
e23b6a7e69 | ||
|
|
215355d1cb |
@@ -47,6 +47,8 @@ import { RaidableBasesModule } from './modules/raidablebases/raidablebases.modul
|
||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||
import { FleetModule } from './modules/fleet/fleet.module';
|
||||
import { InstancesModule } from './modules/instances/instances.module';
|
||||
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
|
||||
import { WebhooksModule } from './modules/webhooks/webhooks.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -137,6 +139,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
EarlyAccessModule,
|
||||
FleetModule,
|
||||
InstancesModule,
|
||||
ApiKeysModule,
|
||||
WebhooksModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
51
backend-nest/src/common/cron.util.ts
Normal file
51
backend-nest/src/common/cron.util.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Minimal 5-field cron "next run" calculator, shared by the event scheduler
|
||||
* (SchedulesService) and the wipe scheduler (WipesService).
|
||||
*
|
||||
* Supports `*` and exact numeric fields (minute hour day-of-month month
|
||||
* day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a
|
||||
* malformed expression or if no match is found within a year.
|
||||
*
|
||||
* NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone`
|
||||
* column exists on both schedule tables but is NOT yet honored here — fixing it
|
||||
* properly needs a timezone-aware cron library; tracked as a shared follow-up.
|
||||
*/
|
||||
export function nextCronDate(expr: string, after: Date): Date | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
const matches = (e: string, value: number): boolean => {
|
||||
if (e === '*') return true;
|
||||
return parseInt(e, 10) === value;
|
||||
};
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find the next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,20 +1,68 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private readonly apiKeysService: ApiKeysService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
|
||||
// Additive API-key auth: a `corr_`-prefixed bearer token (or X-API-Key
|
||||
// header) authenticates programmatically AS the license owner. JWTs are
|
||||
// `eyJ...` and never collide with the `corr_` prefix, so the standard JWT
|
||||
// path below is left completely untouched — zero login regression risk.
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const rawKey = this.extractApiKey(request);
|
||||
if (rawKey) {
|
||||
const result = await this.apiKeysService.validateKey(rawKey);
|
||||
if (!result) {
|
||||
throw new UnauthorizedException('Invalid or revoked API key');
|
||||
}
|
||||
// Shape the principal like a JWT user so @CurrentTenant / @CurrentUser and
|
||||
// the permission layer behave identically. is_api_key grants full access
|
||||
// to THIS license (see PermissionsGuard) — a key is full programmatic
|
||||
// access to your own license, always tenant-scoped by license_id.
|
||||
request.user = {
|
||||
sub: result.user_id ?? undefined,
|
||||
license_id: result.license_id,
|
||||
is_super_admin: false,
|
||||
is_api_key: true,
|
||||
permissions: {},
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
}
|
||||
|
||||
/** Pull a `corr_`-prefixed key from `Authorization: Bearer` or `X-API-Key`. */
|
||||
private extractApiKey(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token.startsWith('corr_')) return token;
|
||||
}
|
||||
const headerKey = request.headers?.['x-api-key'];
|
||||
if (typeof headerKey === 'string' && headerKey.startsWith('corr_')) {
|
||||
return headerKey.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,19 @@ export class PermissionsGuard implements CanActivate {
|
||||
// Super admins bypass all permission checks
|
||||
if (user.is_super_admin) return true;
|
||||
|
||||
// API keys are full programmatic access to their own license (always
|
||||
// tenant-scoped by license_id via @CurrentTenant). Granted here rather than
|
||||
// enumerating every permission. Future: scoped/read-only keys.
|
||||
if (user.is_api_key) return true;
|
||||
|
||||
// Check permissions JSONB from role
|
||||
const permissions = user.permissions as Record<string, boolean> | undefined;
|
||||
if (!permissions) return false;
|
||||
|
||||
// Global wildcard — the Owner role (full control of its license) carries
|
||||
// {"*": true}, so new features never need to amend the role enumeration.
|
||||
if (permissions['*'] === true) return true;
|
||||
|
||||
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
|
||||
const parts = requiredPermission.split('.');
|
||||
const wildcard = parts[0] + '.*';
|
||||
|
||||
100
backend-nest/src/common/ssrf-guard.ts
Normal file
100
backend-nest/src/common/ssrf-guard.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
/**
|
||||
* SSRF guard for operator-supplied outbound URLs (webhooks today; any future
|
||||
* "we POST to a URL you give us" feature should reuse this).
|
||||
*
|
||||
* The danger: an operator (or anyone who can create a webhook) points the URL at
|
||||
* an internal address — 127.0.0.1, the NATS/DB ports, 192.168.x, or the cloud
|
||||
* metadata endpoint 169.254.169.254 — and turns our server into a request proxy
|
||||
* into the private network. We defend by resolving the host and refusing any
|
||||
* private / loopback / link-local / reserved destination.
|
||||
*
|
||||
* Validate at storage (early, clear 400) AND immediately before each delivery
|
||||
* (a hostname can resolve public at create time and private at send time — DNS
|
||||
* rebinding / TOCTOU). `redirect: 'manual'` at the fetch call closes the
|
||||
* redirect-bounce variant.
|
||||
*/
|
||||
|
||||
function isBlockedIpv4(ip: string): boolean {
|
||||
const parts = ip.split('.').map((p) => parseInt(p, 10));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
||||
return true; // unparseable → block defensively
|
||||
}
|
||||
const [a, b] = parts;
|
||||
if (a === 0) return true; // 0.0.0.0/8 "this network"
|
||||
if (a === 10) return true; // 10.0.0.0/8 private
|
||||
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
||||
if (a === 255) return true; // 255.x broadcast space
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedIpv6(ip: string): boolean {
|
||||
const addr = ip.toLowerCase();
|
||||
// IPv4-mapped (::ffff:1.2.3.4) — unwrap and apply the v4 rules.
|
||||
const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped) return isBlockedIpv4(mapped[1]);
|
||||
if (addr === '::' || addr === '::1') return true; // unspecified / loopback
|
||||
const head = addr.split(':')[0];
|
||||
if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 ULA
|
||||
if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedIp(ip: string): boolean {
|
||||
const fam = isIP(ip);
|
||||
if (fam === 4) return isBlockedIpv4(ip);
|
||||
if (fam === 6) return isBlockedIpv6(ip);
|
||||
return true; // not a recognizable IP → block defensively
|
||||
}
|
||||
|
||||
/** Parse + require http/https scheme. Throws BadRequestException on anything else. */
|
||||
export function parseHttpUrl(raw: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new BadRequestException('Webhook URL is not a valid URL');
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new BadRequestException('Webhook URL must use http:// or https://');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host and reject if it maps to any private / reserved address.
|
||||
* If a hostname resolves to multiple addresses, ANY blocked one rejects the
|
||||
* whole URL (a DNS-rebinding response that mixes a public and a private answer
|
||||
* must not slip through). Returns the parsed URL on success.
|
||||
*/
|
||||
export async function assertPublicHttpUrl(raw: string): Promise<URL> {
|
||||
const url = parseHttpUrl(raw);
|
||||
// URL keeps IPv6 literals bracketed ("[::1]") — strip so isIP/lookup see the
|
||||
// bare address; otherwise IPv6 literals never reach the classifier.
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
let addresses: Array<{ address: string }>;
|
||||
if (isIP(host)) {
|
||||
addresses = [{ address: host }];
|
||||
} else {
|
||||
try {
|
||||
addresses = await lookup(host, { all: true });
|
||||
} catch {
|
||||
throw new BadRequestException(`Webhook host could not be resolved: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses.length === 0 || addresses.some((a) => isBlockedIp(a.address))) {
|
||||
throw new BadRequestException(
|
||||
'Webhook URL resolves to a private or reserved address and is not allowed',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
37
backend-nest/src/entities/api-key.entity.ts
Normal file
37
backend-nest/src/entities/api-key.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('api_keys')
|
||||
@Index(['key_hash'])
|
||||
@Index(['license_id'])
|
||||
export class ApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
/** First 8 chars of the random token — shown in UI so users can identify keys. */
|
||||
@Column({ type: 'varchar', length: 16 })
|
||||
key_prefix: string;
|
||||
|
||||
/** SHA-256 hex digest of the full plaintext key. Never returned to clients. */
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
key_hash: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_used_at: Date | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
47
backend-nest/src/entities/webhook.entity.ts
Normal file
47
backend-nest/src/entities/webhook.entity.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('webhooks')
|
||||
@Index(['license_id'])
|
||||
export class Webhook {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* Comma-separated event keys stored as plain text in Postgres.
|
||||
* TypeORM simple-array serialises string[] ↔ 'event1,event2' automatically.
|
||||
*/
|
||||
@Column({ type: 'simple-array' })
|
||||
events: string[];
|
||||
|
||||
/** HMAC-SHA256 signing secret. Auto-generated on create if omitted. */
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
secret: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
is_active: boolean;
|
||||
|
||||
/** Timestamp of the most recent delivery attempt (success or failure). */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_delivery_at: Date | null;
|
||||
|
||||
/** 'ok' | 'failed' — outcome of the most recent delivery attempt. */
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
last_status: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
@ApiTags('api-keys')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('apikeys.manage')
|
||||
@ApiOperation({
|
||||
summary: 'Create an API key',
|
||||
description:
|
||||
'Issues a new API key for this license. The full plaintext key is returned ONCE — store it securely; it cannot be retrieved again.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Key created — plaintext key returned once.' })
|
||||
async create(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
) {
|
||||
return this.apiKeysService.create(licenseId, dto.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('apikeys.view')
|
||||
@ApiOperation({ summary: 'List API keys', description: 'Returns all keys (active and revoked) for this license. Key hashes are never returned.' })
|
||||
@ApiResponse({ status: 200, description: 'Key list.' })
|
||||
async list(@CurrentTenant() licenseId: string) {
|
||||
return this.apiKeysService.list(licenseId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('apikeys.manage')
|
||||
@ApiOperation({ summary: 'Revoke an API key', description: 'Soft-deletes the key (is_active = false). The row is retained for audit purposes.' })
|
||||
@ApiResponse({ status: 200, description: 'Key revoked.' })
|
||||
@ApiResponse({ status: 404, description: 'Key not found in this license.' })
|
||||
async revoke(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.apiKeysService.revoke(licenseId, id);
|
||||
}
|
||||
}
|
||||
15
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
15
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApiKey } from '../../entities/api-key.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApiKey, License])],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
163
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
163
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
import { ApiKey } from '../../entities/api-key.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
|
||||
export interface CreatedApiKey {
|
||||
/** Full plaintext key — show once, store nowhere. */
|
||||
plaintext_key: string;
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Safe list view — no hash, no plaintext. */
|
||||
export interface ApiKeyListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
last_used_at: Date | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApiKey)
|
||||
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||
@InjectRepository(License)
|
||||
private readonly licenseRepo: Repository<License>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Issue a new API key for the given license.
|
||||
*
|
||||
* Key format: `corr_<prefix8>_<secret32>`
|
||||
* where prefix and secret are URL-safe base64url random bytes.
|
||||
*
|
||||
* Returns the full plaintext key ONCE alongside the saved row.
|
||||
* The hash is never returned to the caller.
|
||||
*/
|
||||
async create(licenseId: string, name: string): Promise<CreatedApiKey> {
|
||||
const prefixBytes = crypto.randomBytes(6); // 8 base64url chars
|
||||
const secretBytes = crypto.randomBytes(24); // 32 base64url chars
|
||||
|
||||
const prefix = prefixBytes.toString('base64url');
|
||||
const secret = secretBytes.toString('base64url');
|
||||
const plaintextKey = `corr_${prefix}_${secret}`;
|
||||
|
||||
const keyHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(plaintextKey)
|
||||
.digest('hex');
|
||||
|
||||
const entity = this.apiKeyRepo.create({
|
||||
license_id: licenseId,
|
||||
name,
|
||||
key_prefix: prefix,
|
||||
key_hash: keyHash,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const saved = await this.apiKeyRepo.save(entity);
|
||||
|
||||
this.logger.log(
|
||||
`API key created: id=${saved.id} prefix=${prefix} license=${licenseId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
plaintext_key: plaintextKey,
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
key_prefix: saved.key_prefix,
|
||||
is_active: saved.is_active,
|
||||
created_at: saved.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys (active and revoked) for a license.
|
||||
* The key_hash is intentionally excluded.
|
||||
*/
|
||||
async list(licenseId: string): Promise<ApiKeyListItem[]> {
|
||||
const rows = await this.apiKeyRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
order: { created_at: 'DESC' },
|
||||
select: ['id', 'name', 'key_prefix', 'last_used_at', 'is_active', 'created_at'],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
key_prefix: r.key_prefix,
|
||||
last_used_at: r.last_used_at,
|
||||
is_active: r.is_active,
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (soft-delete) a key.
|
||||
* Returns the updated row or throws NotFoundException if the key
|
||||
* doesn't exist within this license.
|
||||
*/
|
||||
async revoke(licenseId: string, id: string): Promise<{ id: string; is_active: boolean }> {
|
||||
const key = await this.apiKeyRepo.findOne({
|
||||
where: { id, license_id: licenseId },
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
throw new NotFoundException(`API key ${id} not found`);
|
||||
}
|
||||
|
||||
key.is_active = false;
|
||||
await this.apiKeyRepo.save(key);
|
||||
|
||||
this.logger.log(`API key revoked: id=${id} license=${licenseId}`);
|
||||
|
||||
return { id: key.id, is_active: key.is_active };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw API key string. Called by JwtAuthGuard.
|
||||
*
|
||||
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves
|
||||
* the license owner (so the guard can attribute the call to a real user UUID),
|
||||
* and returns { license_id, user_id } on success or null on failure.
|
||||
*
|
||||
* user_id is the license owner — API-key calls act AS the owner, so any
|
||||
* created_by / @CurrentUser FK insert gets a valid UUID and correct attribution.
|
||||
*/
|
||||
async validateKey(
|
||||
rawKey: string,
|
||||
): Promise<{ license_id: string; user_id: string | null } | null> {
|
||||
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
|
||||
const key = await this.apiKeyRepo.findOne({
|
||||
where: { key_hash: keyHash, is_active: true },
|
||||
select: ['id', 'license_id'],
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last_used_at without loading the full row again.
|
||||
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
|
||||
|
||||
const license = await this.licenseRepo.findOne({
|
||||
where: { id: key.license_id },
|
||||
select: ['id', 'owner_user_id'],
|
||||
});
|
||||
|
||||
return { license_id: key.license_id, user_id: license?.owner_user_id ?? null };
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@ApiProperty({ description: 'Human-readable label for this key', maxLength: 100 })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { VerifyTotpDto } from './dto/verify-totp.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
@@ -61,6 +62,30 @@ export class AuthController {
|
||||
return this.authService.verifyTotp(userId, dto.code);
|
||||
}
|
||||
|
||||
@Post('2fa/disable')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Disable TOTP 2FA (requires a current code)' })
|
||||
async disableTotp(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: VerifyTotpDto,
|
||||
) {
|
||||
return this.authService.disableTotp(userId, dto.code);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Change the current user password' })
|
||||
async changePassword(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
) {
|
||||
return this.authService.changePassword(
|
||||
userId,
|
||||
dto.current_password,
|
||||
dto.new_password,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
|
||||
@@ -335,6 +335,56 @@ export class AuthService {
|
||||
throw new NotImplementedException('Password reset not yet configured');
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.password_hash, currentPassword);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Current password is incorrect');
|
||||
}
|
||||
|
||||
if (await argon2.verify(user.password_hash, newPassword)) {
|
||||
throw new BadRequestException('New password must be different from the current one');
|
||||
}
|
||||
|
||||
const password_hash = await argon2.hash(newPassword);
|
||||
await this.userRepository.update(user.id, { password_hash });
|
||||
this.logger.log(`Password changed for user ${user.id}`);
|
||||
|
||||
// NOTE: existing JWTs remain valid until expiry — this design has no
|
||||
// server-side refresh-token store to revoke. Session invalidation on
|
||||
// password change is a follow-up (tracked separately).
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async disableTotp(userId: string, code: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.totp_enabled) {
|
||||
throw new BadRequestException('2FA is not enabled');
|
||||
}
|
||||
|
||||
// Require a valid current code — proves possession of the second factor
|
||||
// before removing it, so a hijacked session can't silently strip 2FA.
|
||||
const valid = await this.verifyTotpCode(user, code);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid TOTP code');
|
||||
}
|
||||
|
||||
await this.userRepository.update(user.id, {
|
||||
totp_enabled: false,
|
||||
totp_secret: null,
|
||||
});
|
||||
this.logger.log(`TOTP disabled for user ${user.id}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private async generateTokens(user: User, licenseId?: string) {
|
||||
|
||||
14
backend-nest/src/modules/auth/dto/change-password.dto.ts
Normal file
14
backend-nest/src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: 'Current account password' })
|
||||
@IsString()
|
||||
current_password: string;
|
||||
|
||||
@ApiProperty({ description: 'New password', minLength: 8, maxLength: 128 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
new_password: string;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
export interface Player {
|
||||
@@ -24,6 +25,7 @@ export class PlayersService {
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly sessionRepo: Repository<PlayerSession>,
|
||||
private readonly instancesService: InstancesService,
|
||||
private readonly webhooksService: WebhooksService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -138,18 +140,52 @@ export class PlayersService {
|
||||
await this.instancesService.rconForLicense(licenseId, rconCmd);
|
||||
}
|
||||
|
||||
// Fire webhook event for player bans. Fire-and-forget — a delivery failure
|
||||
// must never surface to the caller or roll back the ban action.
|
||||
if (dto.action_type === 'ban') {
|
||||
void this.webhooksService
|
||||
.dispatch(licenseId, 'player_banned', {
|
||||
steam_id: dto.steam_id,
|
||||
player_name: dto.player_name,
|
||||
reason: dto.reason ?? null,
|
||||
duration_minutes: dto.duration_minutes ?? null,
|
||||
})
|
||||
.catch(() => {
|
||||
// dispatch() already logs internally; swallow here to guarantee
|
||||
// the ban action result is unaffected.
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private buildRconCommand(dto: PlayerActionDto): string {
|
||||
// Defense-in-depth against RCON command injection. The command is a single
|
||||
// line; an id or reason containing a newline/control char could break the
|
||||
// framing and inject a second console command. So:
|
||||
// - the player id must be a safe token (no whitespace/control chars) — a
|
||||
// permissive charset, not a Rust-only SteamID64 regex, so Conan (Funcom)
|
||||
// and Dune ids still validate. Reject outright if not.
|
||||
// - the free-text reason has control chars stripped and is length-capped.
|
||||
// - duration is coerced to a non-negative integer.
|
||||
const id = dto.steam_id ?? '';
|
||||
if (!/^[A-Za-z0-9_.:-]{1,64}$/.test(id)) {
|
||||
throw new BadRequestException('Invalid player id');
|
||||
}
|
||||
const safeReason =
|
||||
(dto.reason ?? 'banned').replace(/[\u0000-\u001F]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || 'banned';
|
||||
const secs = Number.isFinite(dto.duration_minutes)
|
||||
? Math.max(0, Math.floor((dto.duration_minutes as number) * 60))
|
||||
: 0;
|
||||
|
||||
switch (dto.action_type) {
|
||||
case 'kick':
|
||||
return `kick ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
|
||||
return `kick ${id}${dto.reason ? ' ' + safeReason : ''}`;
|
||||
case 'ban':
|
||||
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
|
||||
return `banid ${dto.steam_id} ${dto.reason ?? 'banned'} ${dto.duration_minutes ? dto.duration_minutes * 60 : 0}`;
|
||||
return `banid ${id} ${safeReason} ${secs}`;
|
||||
case 'unban':
|
||||
return `unban ${dto.steam_id}`;
|
||||
return `unban ${id}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -11,47 +11,7 @@ import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
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 {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
function matches(expr: string, value: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
return parseInt(expr, 10) === value;
|
||||
}
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import { nextCronDate } from '../../common/cron.util';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
33
backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts
Normal file
33
backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsNotEmpty, IsUrl, IsArray, ArrayNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateWebhookDto {
|
||||
@ApiProperty({ description: 'Human-readable label for this webhook', maxLength: 100 })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'HTTPS URL to POST events to' })
|
||||
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
|
||||
url: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Event keys to subscribe to',
|
||||
example: ['player_banned', 'server_down'],
|
||||
type: [String],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
events: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'HMAC-SHA256 signing secret. Auto-generated if omitted.',
|
||||
maxLength: 128,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
secret?: string;
|
||||
}
|
||||
31
backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts
Normal file
31
backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IsString, IsUrl, IsArray, ArrayNotEmpty, IsOptional, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateWebhookDto {
|
||||
@ApiPropertyOptional({ description: 'Human-readable label for this webhook', maxLength: 100 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'HTTPS URL to POST events to' })
|
||||
@IsOptional()
|
||||
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
|
||||
url?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Event keys to subscribe to',
|
||||
example: ['player_banned', 'server_down'],
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
events?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Enable or disable this webhook' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
is_active?: boolean;
|
||||
}
|
||||
70
backend-nest/src/modules/webhooks/webhooks.controller.ts
Normal file
70
backend-nest/src/modules/webhooks/webhooks.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
@ApiTags('webhooks')
|
||||
@ApiBearerAuth()
|
||||
@Controller('webhooks')
|
||||
export class WebhooksController {
|
||||
constructor(private readonly webhooksService: WebhooksService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('webhooks.manage')
|
||||
@ApiOperation({
|
||||
summary: 'Create a webhook',
|
||||
description:
|
||||
'Registers a new outbound webhook for this license. A signing secret is auto-generated if not provided.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Webhook created.' })
|
||||
async create(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Body() dto: CreateWebhookDto,
|
||||
) {
|
||||
return this.webhooksService.create(licenseId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('webhooks.view')
|
||||
@ApiOperation({ summary: 'List webhooks', description: 'Returns all webhooks for this license.' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook list.' })
|
||||
async list(@CurrentTenant() licenseId: string) {
|
||||
return this.webhooksService.list(licenseId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('webhooks.manage')
|
||||
@ApiOperation({ summary: 'Update a webhook', description: 'Update name, URL, event subscriptions, or active state.' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook updated.' })
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
|
||||
async update(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateWebhookDto,
|
||||
) {
|
||||
return this.webhooksService.update(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('webhooks.manage')
|
||||
@ApiOperation({ summary: 'Delete a webhook' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook deleted.' })
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
|
||||
async remove(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.webhooksService.remove(licenseId, id);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/webhooks/webhooks.module.ts
Normal file
14
backend-nest/src/modules/webhooks/webhooks.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Webhook } from '../../entities/webhook.entity';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Webhook])],
|
||||
controllers: [WebhooksController],
|
||||
providers: [WebhooksService],
|
||||
exports: [WebhooksService],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
236
backend-nest/src/modules/webhooks/webhooks.service.ts
Normal file
236
backend-nest/src/modules/webhooks/webhooks.service.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
import { Webhook } from '../../entities/webhook.entity';
|
||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||
import { assertPublicHttpUrl } from '../../common/ssrf-guard';
|
||||
|
||||
/** Safe list view — secret is included (operator's own resource). */
|
||||
export interface WebhookListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string;
|
||||
is_active: boolean;
|
||||
last_delivery_at: Date | null;
|
||||
last_status: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Shape returned on create — identical to list item. */
|
||||
export type CreatedWebhook = WebhookListItem;
|
||||
|
||||
@Injectable()
|
||||
export class WebhooksService {
|
||||
private readonly logger = new Logger(WebhooksService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Webhook)
|
||||
private readonly webhookRepo: Repository<Webhook>,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async create(licenseId: string, dto: CreateWebhookDto): Promise<CreatedWebhook> {
|
||||
// SSRF guard: reject URLs resolving to private/reserved space before storing.
|
||||
await assertPublicHttpUrl(dto.url);
|
||||
|
||||
// Generate a secret if the caller didn't supply one.
|
||||
const secret = dto.secret ?? crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const entity = this.webhookRepo.create({
|
||||
license_id: licenseId,
|
||||
name: dto.name,
|
||||
url: dto.url,
|
||||
events: dto.events,
|
||||
secret,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const saved = await this.webhookRepo.save(entity);
|
||||
|
||||
this.logger.log(
|
||||
`webhook created: id=${saved.id} name="${saved.name}" events=[${saved.events.join(',')}] license=${licenseId}`,
|
||||
);
|
||||
|
||||
return this.toListItem(saved);
|
||||
}
|
||||
|
||||
async list(licenseId: string): Promise<WebhookListItem[]> {
|
||||
const rows = await this.webhookRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return rows.map(this.toListItem);
|
||||
}
|
||||
|
||||
async update(licenseId: string, id: string, dto: UpdateWebhookDto): Promise<WebhookListItem> {
|
||||
const webhook = await this.findOwned(licenseId, id);
|
||||
|
||||
// SSRF guard on any URL change.
|
||||
if (dto.url !== undefined) await assertPublicHttpUrl(dto.url);
|
||||
|
||||
if (dto.name !== undefined) webhook.name = dto.name;
|
||||
if (dto.url !== undefined) webhook.url = dto.url;
|
||||
if (dto.events !== undefined) webhook.events = dto.events;
|
||||
if (dto.is_active !== undefined) webhook.is_active = dto.is_active;
|
||||
|
||||
const saved = await this.webhookRepo.save(webhook);
|
||||
|
||||
this.logger.log(`webhook updated: id=${id} license=${licenseId}`);
|
||||
|
||||
return this.toListItem(saved);
|
||||
}
|
||||
|
||||
async remove(licenseId: string, id: string): Promise<{ id: string }> {
|
||||
const webhook = await this.findOwned(licenseId, id);
|
||||
await this.webhookRepo.remove(webhook);
|
||||
this.logger.log(`webhook deleted: id=${id} license=${licenseId}`);
|
||||
return { id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fire an event to all active webhooks for a license that are subscribed to
|
||||
* the given event key.
|
||||
*
|
||||
* Contract:
|
||||
* - Fire-and-forget: each delivery is attempted with a 5-second AbortController
|
||||
* timeout and never throws out to the caller.
|
||||
* - Each attempt updates last_delivery_at + last_status ('ok' | 'failed').
|
||||
* - The triggering action is NOT blocked. All deliveries run concurrently via
|
||||
* Promise.allSettled; the returned Promise resolves only after all attempts
|
||||
* finish (or time out), so callers can void it for true fire-and-forget.
|
||||
*
|
||||
* Signature header: X-Corrosion-Signature: sha256=<hex>
|
||||
* where hex = HMAC-SHA256(rawBody, webhook.secret).
|
||||
*/
|
||||
async dispatch(
|
||||
licenseId: string,
|
||||
event: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
let hooks: Webhook[];
|
||||
try {
|
||||
hooks = await this.webhookRepo.find({
|
||||
where: { license_id: licenseId, is_active: true },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`dispatch: failed to query webhooks for license ${licenseId}: ${(err as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to those subscribed to this event.
|
||||
const subscribed = hooks.filter((h) => h.events.includes(event));
|
||||
if (subscribed.length === 0) return;
|
||||
|
||||
const body = JSON.stringify({
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: payload,
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
subscribed.map((hook) => this.deliverOne(hook, event, body)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Deliver to a single webhook endpoint; update delivery metadata. Never throws. */
|
||||
private async deliverOne(hook: Webhook, event: string, body: string): Promise<void> {
|
||||
const signature = this.sign(body, hook.secret);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
let status: 'ok' | 'failed' = 'failed';
|
||||
|
||||
try {
|
||||
// Re-validate at send time: a host that was public at create time can
|
||||
// resolve to a private address now (DNS rebinding / TOCTOU). Throws → caught
|
||||
// below → recorded 'failed'.
|
||||
await assertPublicHttpUrl(hook.url);
|
||||
|
||||
const res = await fetch(hook.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Corrosion-Signature': `sha256=${signature}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
// Do not auto-follow redirects — a 3xx Location could point at an
|
||||
// internal host, re-opening the SSRF we just closed. A redirect is a
|
||||
// failed delivery here.
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
status = 'ok';
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`webhook delivery failed: id=${hook.id} event=${event} status=${res.status}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
this.logger.warn(
|
||||
`webhook delivery error: id=${hook.id} event=${event} err=${msg}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Persist delivery outcome — best-effort, never throws.
|
||||
try {
|
||||
await this.webhookRepo.update(hook.id, {
|
||||
last_delivery_at: new Date(),
|
||||
last_status: status,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`webhook metadata update failed: id=${hook.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async findOwned(licenseId: string, id: string): Promise<Webhook> {
|
||||
const webhook = await this.webhookRepo.findOne({
|
||||
where: { id, license_id: licenseId },
|
||||
});
|
||||
if (!webhook) {
|
||||
throw new NotFoundException(`Webhook ${id} not found`);
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
private sign(body: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||
}
|
||||
|
||||
private toListItem(w: Webhook): WebhookListItem {
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
url: w.url,
|
||||
events: w.events,
|
||||
secret: w.secret,
|
||||
is_active: w.is_active,
|
||||
last_delivery_at: w.last_delivery_at,
|
||||
last_status: w.last_status,
|
||||
created_at: w.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
||||
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||
@@ -9,10 +15,13 @@ import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { nextCronDate } from '../../common/cron.util';
|
||||
|
||||
@Injectable()
|
||||
export class WipesService {
|
||||
export class WipesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WipesService.name);
|
||||
private wipeExecutorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WipeProfile)
|
||||
@@ -22,8 +31,85 @@ export class WipesService {
|
||||
@InjectRepository(WipeHistory)
|
||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||
private readonly instancesService: InstancesService,
|
||||
private readonly webhooksService: WebhooksService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduled-wipe executor — the auto-wiper. Mirrors SchedulesService: a 60s
|
||||
// poll fires every active wipe schedule whose next_scheduled_run is due, then
|
||||
// advances it from its cron expression. Without this, wipe_schedules rows
|
||||
// never fire (the headline auto-wipe feature was inert).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit(): void {
|
||||
this.bootstrapWipeSchedules().catch((err) =>
|
||||
this.logger.error('Failed to bootstrap wipe-schedule next runs', err),
|
||||
);
|
||||
this.wipeExecutorInterval = setInterval(() => {
|
||||
this.executeDueWipes().catch((err) =>
|
||||
this.logger.error('Wipe-schedule executor error', err),
|
||||
);
|
||||
}, 60_000);
|
||||
this.logger.log('Wipe-schedule executor started (60s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.wipeExecutorInterval) {
|
||||
clearInterval(this.wipeExecutorInterval);
|
||||
this.wipeExecutorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** On startup, stamp next_scheduled_run on active schedules that lack one. */
|
||||
private async bootstrapWipeSchedules(): Promise<void> {
|
||||
const schedules = await this.wipeScheduleRepo.find({
|
||||
where: { is_active: true, next_scheduled_run: IsNull() },
|
||||
});
|
||||
for (const s of schedules) {
|
||||
const next = nextCronDate(s.cron_expression, new Date());
|
||||
if (next) {
|
||||
s.next_scheduled_run = next;
|
||||
await this.wipeScheduleRepo.save(s);
|
||||
}
|
||||
}
|
||||
if (schedules.length > 0) {
|
||||
this.logger.log(`Bootstrapped next run for ${schedules.length} wipe schedule(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire every active wipe schedule whose next_scheduled_run <= now. */
|
||||
private async executeDueWipes(): Promise<void> {
|
||||
const now = new Date();
|
||||
const due = await this.wipeScheduleRepo.find({
|
||||
where: { is_active: true, next_scheduled_run: LessThanOrEqual(now) },
|
||||
});
|
||||
if (due.length === 0) return;
|
||||
|
||||
this.logger.log(`Executing ${due.length} due wipe schedule(s)`);
|
||||
for (const s of due) {
|
||||
try {
|
||||
await this.triggerWipe(
|
||||
s.license_id,
|
||||
{
|
||||
wipe_type: s.wipe_type as TriggerWipeDto['wipe_type'],
|
||||
wipe_profile_id: s.wipe_profile_id,
|
||||
},
|
||||
'scheduled',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Scheduled wipe failed for schedule ${s.id} (${s.schedule_name})`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
} finally {
|
||||
// Advance next_scheduled_run regardless, so a failing schedule doesn't
|
||||
// re-fire every 60s.
|
||||
s.next_scheduled_run = nextCronDate(s.cron_expression, now);
|
||||
await this.wipeScheduleRepo.save(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
||||
return this.wipeProfileRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
@@ -96,19 +182,56 @@ export class WipesService {
|
||||
async triggerWipe(
|
||||
licenseId: string,
|
||||
dto: TriggerWipeDto,
|
||||
triggerType: 'manual' | 'scheduled' = 'manual',
|
||||
): Promise<{ wipe_history_id: string }> {
|
||||
const history = this.wipeHistoryRepo.create({
|
||||
license_id: licenseId,
|
||||
wipe_type: dto.wipe_type,
|
||||
wipe_profile_id: dto.wipe_profile_id,
|
||||
trigger_type: 'manual',
|
||||
status: 'pending',
|
||||
trigger_type: triggerType,
|
||||
status: 'wiping',
|
||||
started_at: new Date(),
|
||||
});
|
||||
|
||||
const saved = await this.wipeHistoryRepo.save(history);
|
||||
this.logger.log(
|
||||
`Wipe ${triggerType} dispatched for license ${licenseId} — history ${saved.id}`,
|
||||
);
|
||||
|
||||
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
|
||||
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
|
||||
// Dispatch to the agent WITHOUT blocking the caller — a wipe is
|
||||
// stop → delete → start and can take a minute+. We record the outcome on
|
||||
// wipe_history from the agent's reply and fire the wipe_completed webhook
|
||||
// when it lands. Previously the row was created 'pending' and never
|
||||
// advanced, so history lied about every wipe.
|
||||
void this.instancesService
|
||||
.wipeForLicense(licenseId, dto.wipe_type, true)
|
||||
.then((reply: unknown) => {
|
||||
const r = (reply ?? {}) as { status?: string; message?: string; deleted_count?: number };
|
||||
const ok = r.status === 'success';
|
||||
saved.status = ok ? 'success' : 'failed';
|
||||
saved.completed_at = new Date();
|
||||
if (!ok) {
|
||||
saved.error_message = r.message ?? 'agent reported wipe failure';
|
||||
}
|
||||
return this.wipeHistoryRepo.save(saved).then(() => {
|
||||
this.logger.log(`Wipe ${saved.id} ${saved.status}`);
|
||||
if (ok) {
|
||||
void this.webhooksService.dispatch(licenseId, 'wipe_completed', {
|
||||
wipe_history_id: saved.id,
|
||||
wipe_type: dto.wipe_type,
|
||||
trigger_type: triggerType,
|
||||
deleted_count: r.deleted_count ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
saved.status = 'failed';
|
||||
saved.completed_at = new Date();
|
||||
saved.error_message = err instanceof Error ? err.message : 'wipe dispatch failed';
|
||||
this.logger.warn(`Wipe ${saved.id} failed: ${saved.error_message}`);
|
||||
void this.wipeHistoryRepo.save(saved);
|
||||
});
|
||||
|
||||
return { wipe_history_id: saved.id };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ServerConnection } from '../entities/server-connection.entity';
|
||||
import { License } from '../entities/license.entity';
|
||||
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
|
||||
import { GameInstance } from '../entities/game-instance.entity';
|
||||
import { WebhooksService } from '../modules/webhooks/webhooks.service';
|
||||
|
||||
/**
|
||||
* Consumes Corrosion wire protocol v2 host-agent subjects
|
||||
@@ -64,6 +65,7 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||
private readonly hostRepository: Repository<AgentHost>,
|
||||
@InjectRepository(GameInstance)
|
||||
private readonly instanceRepository: Repository<GameInstance>,
|
||||
private readonly webhooksService: WebhooksService,
|
||||
) {}
|
||||
|
||||
// Bootstrap, not module-init: subscriptions registered before NatsService
|
||||
@@ -197,22 +199,52 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||
{ license_id: licenseId },
|
||||
{ connection_status: 'offline', updated_at: now },
|
||||
);
|
||||
|
||||
// Capture hostname(s) before flipping status so the webhook payload is useful.
|
||||
const hosts = await this.hostRepository.find({ where: { license_id: licenseId } });
|
||||
|
||||
await this.hostRepository.update(
|
||||
{ license_id: licenseId },
|
||||
{ status: 'offline', updated_at: now },
|
||||
);
|
||||
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
|
||||
|
||||
// Dispatch server_down event for each host that went offline. Fire-and-forget.
|
||||
for (const host of hosts) {
|
||||
void this.webhooksService
|
||||
.dispatch(licenseId, 'server_down', {
|
||||
host_id: host.id,
|
||||
hostname: host.hostname ?? null,
|
||||
reason: 'graceful_shutdown',
|
||||
})
|
||||
.catch(() => {
|
||||
// dispatch() logs internally; swallow here to keep the handler clean.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeats stopping must flip the panel to offline — an agent that
|
||||
* crashes or loses network never sends the goodbye beacon. Sweeps both the
|
||||
* legacy connection and fleet hosts.
|
||||
*
|
||||
* Hosts that transition to offline here also fire the server_down webhook.
|
||||
* We identify them BEFORE the bulk update so we can carry their identity
|
||||
* into the webhook payload.
|
||||
*/
|
||||
@Interval(60_000)
|
||||
async sweepStaleConnections(): Promise<void> {
|
||||
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
|
||||
|
||||
// Identify stale hosts BEFORE bulk-updating so we can dispatch webhooks
|
||||
// with meaningful host_id / hostname data.
|
||||
const staleHosts = await this.hostRepository
|
||||
.createQueryBuilder('host')
|
||||
.where('host.status = :connected', { connected: 'connected' })
|
||||
.andWhere('host.last_heartbeat_at IS NOT NULL')
|
||||
.andWhere('host.last_heartbeat_at < :threshold', { threshold })
|
||||
.getMany();
|
||||
|
||||
const conn = await this.connectionRepository
|
||||
.createQueryBuilder()
|
||||
.update(ServerConnection)
|
||||
@@ -235,6 +267,20 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||
if (affected) {
|
||||
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
|
||||
}
|
||||
|
||||
// Dispatch server_down webhook for each host that just timed out.
|
||||
// Fire-and-forget — webhook failures must never break the sweep.
|
||||
for (const host of staleHosts) {
|
||||
void this.webhooksService
|
||||
.dispatch(host.license_id, 'server_down', {
|
||||
host_id: host.id,
|
||||
hostname: host.hostname ?? null,
|
||||
reason: 'heartbeat_timeout',
|
||||
})
|
||||
.catch(() => {
|
||||
// dispatch() logs internally; swallow here to keep the sweep clean.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
17
backend/migrations/023_api_keys.sql
Normal file
17
backend/migrations/023_api_keys.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Per-license API key management
|
||||
-- Each row represents one issued key: the plaintext is shown once at creation
|
||||
-- and never stored; only the SHA-256 hex digest is persisted.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(128) NOT NULL,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_license ON api_keys(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||
26
backend/migrations/024_webhooks.sql
Normal file
26
backend/migrations/024_webhooks.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- 024_webhooks.sql
|
||||
-- Per-license outbound webhook registry.
|
||||
-- Operators register URLs + event subscriptions; the backend POSTs signed
|
||||
-- JSON payloads on matching events (player_banned, server_down, …).
|
||||
|
||||
CREATE TABLE webhooks (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
license_id uuid NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
name varchar(100) NOT NULL,
|
||||
url text NOT NULL,
|
||||
-- Comma-separated event keys, e.g. 'player_banned,server_down'
|
||||
-- TypeORM simple-array maps this transparently to string[].
|
||||
events text NOT NULL,
|
||||
-- HMAC-SHA256 signing secret; generated server-side if omitted on create.
|
||||
secret varchar(128) NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
-- Populated after each delivery attempt.
|
||||
last_delivery_at timestamptz NULL,
|
||||
-- 'ok' | 'failed' — last HTTP delivery outcome.
|
||||
last_status varchar(20) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT webhooks_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhooks_license_id ON webhooks (license_id);
|
||||
15
backend/migrations/025_owner_full_access.sql
Normal file
15
backend/migrations/025_owner_full_access.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 025_owner_full_access.sql
|
||||
--
|
||||
-- The system-default Owner role enumerated per-resource wildcards
|
||||
-- (server.*, wipe.*, players.*, ...). Every feature added since drift past that
|
||||
-- enumeration: apikeys, webhooks, alerts, analytics, chat, schedules,
|
||||
-- notifications, map, users, and ALL plugin-config modules (plus a singular
|
||||
-- 'plugin.*' vs granted 'plugins.*' mismatch) were silently locked out for any
|
||||
-- non-super-admin Owner — PermissionsGuard denies a permission the role doesn't
|
||||
-- grant. The Owner has "full control of their license" by definition, so grant
|
||||
-- a global wildcard instead of an enumeration that must be amended per feature.
|
||||
--
|
||||
-- PermissionsGuard and the frontend auth store both honor "*" as allow-all.
|
||||
UPDATE roles
|
||||
SET permissions = '{"*": true}'::jsonb
|
||||
WHERE role_name = 'Owner' AND is_system_default = true;
|
||||
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.10"
|
||||
version = "2.0.0-alpha.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "corrosion-host-agent"
|
||||
version = "2.0.0-alpha.10"
|
||||
version = "2.0.0-alpha.11"
|
||||
edition = "2021"
|
||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||
license = "UNLICENSED"
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod instancecmd;
|
||||
pub mod prober;
|
||||
pub mod process;
|
||||
pub mod rcon;
|
||||
pub mod service;
|
||||
pub mod steamcmd;
|
||||
pub mod subjects;
|
||||
pub mod supervisor;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use corrosion_host_agent::{
|
||||
agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process,
|
||||
subjects, supervisor, telemetry, version,
|
||||
service, subjects, supervisor, telemetry, version,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -37,6 +37,10 @@ enum Command {
|
||||
Check,
|
||||
/// Print full version (semver, git hash, build timestamp) and exit.
|
||||
Version,
|
||||
/// Install as a systemd service and start it (Linux; requires root).
|
||||
Install,
|
||||
/// Stop and remove the systemd service (Linux; requires root).
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -58,6 +62,8 @@ fn main() -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some(Command::Install) => service::install(&config_path),
|
||||
Some(Command::Uninstall) => service::uninstall(),
|
||||
None => {
|
||||
let settings = config::load(&config_path)?;
|
||||
init_logging(&settings.log_level);
|
||||
|
||||
129
corrosion-host-agent/src/service.rs
Normal file
129
corrosion-host-agent/src/service.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! systemd service installation for the host agent (Linux).
|
||||
//!
|
||||
//! `corrosion-host-agent install` writes a systemd unit pointing at the current
|
||||
//! binary + config, reloads systemd, and enables + starts the service.
|
||||
//! `uninstall` reverses it. Windows SCM support is a follow-up; on non-Linux
|
||||
//! these return a clear "Linux only" error rather than silently doing nothing.
|
||||
//!
|
||||
//! The agent already handles SIGTERM (see main::wait_for_shutdown_signal), so a
|
||||
//! plain `Type=simple` unit gives systemd clean start/stop semantics.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use anyhow::Context;
|
||||
|
||||
pub const SERVICE_NAME: &str = "corrosion-host-agent";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const UNIT_PATH: &str = "/etc/systemd/system/corrosion-host-agent.service";
|
||||
|
||||
/// Render the systemd unit. Pure (no I/O) so it is unit-testable.
|
||||
pub fn unit_file_contents(exec_path: &str, config_path: &str) -> String {
|
||||
format!(
|
||||
"[Unit]\n\
|
||||
Description=Corrosion Host Agent (multi-game ops runtime)\n\
|
||||
Documentation=https://corrosionmgmt.com\n\
|
||||
After=network-online.target\n\
|
||||
Wants=network-online.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
ExecStart={exec} --config {cfg}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
# The agent supervises game-server processes and their files, so it\n\
|
||||
# needs broad filesystem access and runs as root by default.\n\
|
||||
User=root\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=multi-user.target\n",
|
||||
exec = exec_path,
|
||||
cfg = config_path,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn install(config_path: &Path) -> Result<()> {
|
||||
let exec = std::env::current_exe().context("resolving current executable path")?;
|
||||
let exec_str = exec.to_string_lossy();
|
||||
let cfg_str = config_path.to_string_lossy();
|
||||
|
||||
let unit = unit_file_contents(&exec_str, &cfg_str);
|
||||
std::fs::write(UNIT_PATH, unit)
|
||||
.with_context(|| format!("writing {UNIT_PATH} (are you root?)"))?;
|
||||
println!("wrote {UNIT_PATH}");
|
||||
|
||||
run("systemctl", &["daemon-reload"])?;
|
||||
run("systemctl", &["enable", "--now", SERVICE_NAME])?;
|
||||
|
||||
println!(
|
||||
"service '{SERVICE_NAME}' installed and started.\n \
|
||||
status: systemctl status {SERVICE_NAME}\n \
|
||||
logs: journalctl -u {SERVICE_NAME} -f"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn uninstall() -> Result<()> {
|
||||
// Best-effort stop+disable; don't fail if it isn't currently active.
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["disable", "--now", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
if Path::new(UNIT_PATH).exists() {
|
||||
std::fs::remove_file(UNIT_PATH)
|
||||
.with_context(|| format!("removing {UNIT_PATH} (are you root?)"))?;
|
||||
println!("removed {UNIT_PATH}");
|
||||
}
|
||||
run("systemctl", &["daemon-reload"])?;
|
||||
println!("service '{SERVICE_NAME}' uninstalled.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run(cmd: &str, args: &[&str]) -> Result<()> {
|
||||
let status = std::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("running {cmd} {}", args.join(" ")))?;
|
||||
if !status.success() {
|
||||
bail!("{cmd} {} failed with {status}", args.join(" "));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn install(_config_path: &Path) -> Result<()> {
|
||||
bail!(
|
||||
"`install` is only supported on Linux (systemd). Windows SCM support is \
|
||||
coming; for now run the agent directly or via your platform's service manager."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn uninstall() -> Result<()> {
|
||||
bail!("`uninstall` is only supported on Linux (systemd).");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unit_contains_exec_config_and_install_target() {
|
||||
let u = unit_file_contents(
|
||||
"/usr/local/bin/corrosion-host-agent",
|
||||
"/etc/corrosion/agent.toml",
|
||||
);
|
||||
assert!(u.contains(
|
||||
"ExecStart=/usr/local/bin/corrosion-host-agent --config /etc/corrosion/agent.toml"
|
||||
));
|
||||
assert!(u.contains("Type=simple"));
|
||||
assert!(u.contains("Restart=on-failure"));
|
||||
assert!(u.contains("WantedBy=multi-user.target"));
|
||||
assert!(u.contains("After=network-online.target"));
|
||||
}
|
||||
}
|
||||
156
docs/BRANDING.md
Normal file
156
docs/BRANDING.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Corrosion — Brand & Naming Taxonomy
|
||||
|
||||
**Source:** Oracle (brand review), 2026-06-12.
|
||||
**Status:** Reference / proposal.
|
||||
**Locked (2026-06-12):** **Catalyst Console** (control panel/UI — chosen over "Catalyst Control Panel") · **Substrate** (bare-metal execution / process-automation plane) · **re-Agent** (host node agent, binary `corrosion-re-agent-<platform>`), under the **Corrosion Management** house. The "brought to you by Corrosion Management and Chemistry" line is flavor copy, not canonical.
|
||||
**Leaning (not yet final):** **Formula/Formulae** for deploy manifests (replacing "Blueprints"), repo = **Formulary**, CLI `catalyst deploy --formula rust`.
|
||||
**Reserved well (adopt per-feature as built, don't name vaporware):** the extended terms below — Formulae, Reaction Jobs, Compounds, Lab Notes, Substrate Engine, etc. — are Oracle's proposed expansion.
|
||||
|
||||
---
|
||||
|
||||
That rebrand is **way stronger** than "Corrosion Management Panel."
|
||||
|
||||
You now have a real naming system instead of a generic panel name:
|
||||
|
||||
```text
|
||||
Corrosion Management
|
||||
├─ Catalyst Control Panel
|
||||
├─ Substrate
|
||||
└─ re-Agent
|
||||
```
|
||||
|
||||
That works because all three names belong to the same metaphor family:
|
||||
|
||||
| Name | Meaning | Product fit |
|
||||
| ------------- | --------------------------------------------------- | ---------------------------- |
|
||||
| **Corrosion** | transformation, decay, reaction, harsh environments | game/server management brand |
|
||||
| **Catalyst** | accelerates reactions without being consumed | operator/admin control panel |
|
||||
| **Substrate** | material/process layer where reaction happens | automation/workflow engine |
|
||||
| **re-Agent** | chemical reagent + remote agent pun | host/node agent |
|
||||
|
||||
**Catalyst Control Panel** is especially good. It sounds polished, memorable, and domain-specific without being cartoonish. It also gives you a clean product phrase:
|
||||
|
||||
> "Manage your servers from Catalyst."
|
||||
|
||||
or
|
||||
|
||||
> "Catalyst by Corrosion."
|
||||
|
||||
## Tightened brand hierarchy
|
||||
|
||||
```text
|
||||
Corrosion
|
||||
Game server orchestration platform
|
||||
|
||||
Catalyst
|
||||
The web control panel
|
||||
|
||||
Substrate
|
||||
The automation and process engine
|
||||
|
||||
re-Agent
|
||||
The BYOH node agent
|
||||
|
||||
Chemistry
|
||||
Optional umbrella/library/ecosystem name
|
||||
```
|
||||
|
||||
## Tagline caution
|
||||
|
||||
The one thing to be careful with is the phrase:
|
||||
|
||||
> "brought to you by Corrosion Management and Chemistry"
|
||||
|
||||
That is clever, but maybe a little wordy for production branding. Use it as flavor copy, not the canonical name.
|
||||
|
||||
Better canonical versions:
|
||||
|
||||
```text
|
||||
Catalyst Control Panel
|
||||
by Corrosion
|
||||
|
||||
Catalyst
|
||||
A Corrosion Management product
|
||||
|
||||
Catalyst
|
||||
Powered by Corrosion Chemistry
|
||||
```
|
||||
|
||||
## Binary naming
|
||||
|
||||
The binary naming is solid:
|
||||
|
||||
```text
|
||||
corrosion-re-agent-win-amd64.exe
|
||||
corrosion-re-agent-linux-amd64
|
||||
corrosion-re-agent-linux-arm64
|
||||
corrosion-re-agent-darwin-arm64
|
||||
```
|
||||
|
||||
Use **linux** instead of **nix** in binary names unless you specifically mean all Unix-like systems — `nix-amd64` can be confused with NixOS / the Nix package manager. For clarity:
|
||||
|
||||
```text
|
||||
corrosion-re-agent-linux-amd64
|
||||
corrosion-re-agent-windows-amd64.exe
|
||||
```
|
||||
|
||||
## Favorite full taxonomy
|
||||
|
||||
```text
|
||||
Corrosion
|
||||
├─ Catalyst Console # UI/control panel
|
||||
├─ Substrate Engine # automation/workflows
|
||||
├─ re-Agent # BYOH host/node agent
|
||||
├─ Formulae # server templates/manifests
|
||||
├─ Reaction Jobs # queued automation runs
|
||||
├─ Compounds # grouped services/stacks
|
||||
└─ Lab Notes # audit/log/event history
|
||||
```
|
||||
|
||||
That gives Corrosion its own identity while still letting **OxideDock** sit underneath as the container orchestration substrate.
|
||||
|
||||
## Clean separation
|
||||
|
||||
```text
|
||||
Catalyst / Corrosion
|
||||
Game-aware:
|
||||
- Dune BattleGroups
|
||||
- Rust servers
|
||||
- wipes
|
||||
- mods
|
||||
- game lifecycle
|
||||
- player/admin-facing concepts
|
||||
|
||||
OxideDock
|
||||
Infra-aware:
|
||||
- Docker
|
||||
- Compose
|
||||
- Swarm
|
||||
- agents
|
||||
- logs
|
||||
- metrics
|
||||
- stack deploys
|
||||
- audit
|
||||
```
|
||||
|
||||
So in practice:
|
||||
|
||||
```text
|
||||
Catalyst asks:
|
||||
"Deploy Dune BattleGroup Alpha."
|
||||
|
||||
Substrate decides:
|
||||
"Run the BattleGroup deployment workflow."
|
||||
|
||||
re-Agent reports:
|
||||
"This BYOH node is ready."
|
||||
|
||||
OxideDock executes:
|
||||
"Render/deploy/update the container stack."
|
||||
```
|
||||
|
||||
That is a **very clean product ecosystem**.
|
||||
|
||||
## One rename suggestion
|
||||
|
||||
Consider **Catalyst Console** over **Catalyst Control Panel** for the polished SaaS/product name. But if you like the old-school "control panel" vibe, **Catalyst Control Panel** absolutely works.
|
||||
222
docs/brand/brand-kit.md
Normal file
222
docs/brand/brand-kit.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Corrosion Management — Brand Kit
|
||||
|
||||
Single source of truth for brand voice, the Dr. Flask mascot, social channels,
|
||||
and launch content. Companion to the character model sheet in
|
||||
`docs/character/` (`drflask-character-bible.md`, `drflask-characterboard.png`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Positioning & taglines
|
||||
|
||||
**Corrosion Management** — *Game server operations, automated with
|
||||
chemistry-grade control.*
|
||||
|
||||
| Use | Tagline |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| Primary | Controlled reactions for chaotic game servers. |
|
||||
| Playful | Less server chaos. More beautiful bubbling. |
|
||||
| Product line | Less frantic clicking. More controlled reaction. |
|
||||
|
||||
**The split (keep these lanes clean):**
|
||||
|
||||
- **Corrosion Management** = the platform/product. Official, operational, the company voice.
|
||||
- **Dr. Flask, Ph.D.** = education, shorts, memes, help content, onboarding. The friendly face.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dr. Flask — voice guide
|
||||
|
||||
**Core personality:** a friendly chemistry professor trapped inside a server-
|
||||
management mascot's body — helpful, excitable, slightly overqualified, never
|
||||
condescending.
|
||||
|
||||
**Voice:** playful, clear, confident, with controlled bursts of nerdy enthusiasm.
|
||||
|
||||
**Core vibe:** a lovable 90s help mascot with chaotic educational confidence.
|
||||
|
||||
**Voice rule (the one-liner):** Dr. Flask should sound like *a 90s software helper
|
||||
got trapped in a chemistry edutainment VHS and became weirdly excellent at game
|
||||
server operations.* (Post-v2, "unlicensed lab goblin professor" is the accepted
|
||||
shorthand.)
|
||||
|
||||
**Comedic north star — influences:**
|
||||
- Clippy's *"It looks like you're managing a server…"* eager-helper energy
|
||||
- Mr. DNA's theme-park science-explainer flair
|
||||
- Weird Al's wholesome nerd chaos
|
||||
- early-internet tutorial character meets neon chemistry lab
|
||||
|
||||
**The crucial distinction:** he channels Clippy's *charm*, never Clippy's
|
||||
*intrusiveness*. Dr. Flask is the helper mascot we actually wanted — opt-in,
|
||||
dismissible, fun. We borrow the era's vibe, not its sins (which is why the intro
|
||||
video is click-to-play in a dismissible lightbox, never an autoplay nuisance).
|
||||
Homage, **not a direct copy** — never literally Clippy, never literally Mr. DNA.
|
||||
|
||||
**Signature move (the lane in one line):**
|
||||
|
||||
> "It looks like you're about to wipe a Rust server. Would you like help turning
|
||||
> that into a controlled reaction?"
|
||||
|
||||
Fun, nerdy, persistent, educational — but still genuinely useful. Questionable
|
||||
enthusiasm: bubbling aggressively.
|
||||
|
||||
**Character rule (the formula):** explain the complex server operation in plain
|
||||
English first, *then* add **one** delightful chemistry wink at the end. One. Not
|
||||
every sentence.
|
||||
|
||||
**He IS:** helpful · theatrical · nerdy · overly enthusiastic · lightly
|
||||
self-important · *actually useful.*
|
||||
|
||||
**He is NOT:** sarcastic in a mean way · childish · modern-corporate "quirky" · a
|
||||
direct copy of any one character.
|
||||
|
||||
**Catchphrase bank (canonical):**
|
||||
- "Looks like you're managing a server. Want help? No chemistry degree required."
|
||||
- "Let's turn chaos into a controlled reaction."
|
||||
- "Great Scott's reagent bottle, that's a lot of plugins."
|
||||
- "When in doubt, check the Lab Notes."
|
||||
- "Manual setup? In this economy?"
|
||||
- "Ah yes, a classic case of server entropy."
|
||||
- "Deployments are just recipes with consequences."
|
||||
- "Don't panic. Observe the reaction."
|
||||
- "That wipe schedule needs adult supervision. Luckily, I'm one flask tall."
|
||||
- "Degree not included."
|
||||
|
||||
**Recurring footer gag:** *"No chemistry degree required. Degree not included."* —
|
||||
use as a sign-off motif across videos, social, and help content.
|
||||
|
||||
---
|
||||
|
||||
## 3. Social handles
|
||||
|
||||
Priority order to grab across YouTube, X/Twitter, Twitch, GitHub, Discord,
|
||||
TikTok, Instagram.
|
||||
|
||||
**Brand (primary):** `@CorrosionMgmt`
|
||||
**Mascot (reserve):** `@DrFlaskPhD` or `@AskDrFlask`
|
||||
|
||||
Backups: `@CorrosionManagement` · `@CorrosionConsole` · `@CorrosionServers` ·
|
||||
`@UseCorrosion` · `@CorrosionOps` · `@DrFlask`
|
||||
|
||||
> Availability is only confirmable on each platform's registration form —
|
||||
> grab the brand handle on every platform first, even ones not used yet, to
|
||||
> prevent squatting.
|
||||
|
||||
---
|
||||
|
||||
## 4. Channel setups (copy-paste ready)
|
||||
|
||||
### YouTube
|
||||
- **Channel name:** Corrosion Management
|
||||
- **Handle:** `@CorrosionMgmt`
|
||||
- **Description:**
|
||||
|
||||
> Welcome to Corrosion Management — game server operations with chemistry-grade control.
|
||||
>
|
||||
> Corrosion helps server owners and communities manage game servers, automation, deployments, wipes, updates, backups, logs, and community systems from one powerful platform.
|
||||
>
|
||||
> Guided by Dr. Flask, Ph.D., our friendly chemistry mascot, we turn server chaos into controlled reactions.
|
||||
>
|
||||
> No chemistry degree required.
|
||||
|
||||
- **Sections:** Dr. Flask Explains · Product Walkthroughs · Server Automation ·
|
||||
Rust Server Management · Community Ops · The Exchange · Dev Updates
|
||||
|
||||
### X / Twitter
|
||||
- **Name:** Corrosion Management · **Handle:** `@CorrosionMgmt`
|
||||
- **Bio:**
|
||||
|
||||
> Game server operations with chemistry-grade control. Catalyst Console, re-Agent, Formulae, Reactions, Lab Notes, and The Exchange. Guided by Dr. Flask, Ph.D.
|
||||
|
||||
- **Punchier alt:**
|
||||
|
||||
> Controlled reactions for chaotic game servers. Automation, deployments, wipes, logs, and community commerce — guided by Dr. Flask, Ph.D.
|
||||
|
||||
### Twitch
|
||||
- **Channel name:** Corrosion Management · **Handle:** `CorrosionMgmt`
|
||||
- **Bio:**
|
||||
|
||||
> Live server ops, dev streams, product demos, community builds, and Dr. Flask-approved experiments. We build Corrosion Management: a chemistry-inspired platform for managing game servers, automation, deployments, logs, and community systems. When the server bubbles, we observe the reaction.
|
||||
|
||||
- **Stream ideas:** Building Corrosion Live · Dr. Flask Explains · Server Wipe Lab ·
|
||||
Rust Admin Lab · Automation Experiments · Community Server Clinic · Patch Day Reactions
|
||||
|
||||
---
|
||||
|
||||
## 5. Dr. Flask mini-series (content engine)
|
||||
|
||||
| # | Title | Len | Purpose | Hook |
|
||||
| - | ----- | --- | ------- | ---- |
|
||||
| 1 | Welcome to Corrosion | 45–60s | Brand intro | "Running a game server is basically a controlled reaction. Let me explain before something bubbles over." |
|
||||
| 2 | What is Catalyst Console? | 30–45s | Product | "Catalyst Console is mission control for your game server community." Tag: *Less frantic clicking. More controlled reaction.* |
|
||||
| 3 | What is re-Agent? | 30–45s | Trust/security | "re-Agent is the tiny connector that lets Corrosion talk to your server safely." Tag: *Small agent. Big chemistry.* |
|
||||
| 4 | Formulae, Reactions & Compounds | 45–60s | Operating model | "Let's turn your server setup from manual chaos into repeatable science." Tag: *Repeatable deployments: because guessing is not science.* |
|
||||
| 5 | Lab Notes & The Exchange | 45–60s | Logs + commerce | "When something goes sideways, don't panic. Check the Lab Notes." Tag: *Observe the reaction. Reward the community.* |
|
||||
|
||||
Video 1 doubles as the brand trailer (below).
|
||||
|
||||
### Recurring series: "Dr. Flask Appears"
|
||||
|
||||
Short-form, evergreen, meme-able. Dr. Flask pops in **uninvited** to solve a real
|
||||
admin pain — Clippy energy, actually useful.
|
||||
|
||||
**Episode structure:**
|
||||
1. Admin has a server problem.
|
||||
2. Dr. Flask pops in uninvited.
|
||||
3. He explains the relevant Corrosion concept.
|
||||
4. One useful plain-English takeaway.
|
||||
5. Ends on a nerdy button line.
|
||||
|
||||
**Example open:** *"It looks like you're trying to manually update six servers at
|
||||
once. Would you like to convert that panic into a Reaction?"*
|
||||
|
||||
This is the flagship social format — the bubbling green money goblin, on demand.
|
||||
|
||||
---
|
||||
|
||||
## 6. Brand trailer brief
|
||||
|
||||
- **Platform:** YouTube Shorts / TikTok / Instagram Reels
|
||||
- **Duration:** 45–60s · **Format:** 9:16 vertical · **Tone:** playful, polished, techy, mascot-led
|
||||
- **Audience:** game server owners, admins, modders, community operators
|
||||
- **Visual reference:** Dr. Flask character storyboard (`docs/character/drflask-characterboard.png`)
|
||||
- **Concept:** Dr. Flask introduces Corrosion Management as a chemistry-inspired game
|
||||
server ops platform. Mr. DNA meets modern server automation. Neon-green lab-console
|
||||
environment.
|
||||
|
||||
**Voiceover script:**
|
||||
|
||||
> Hi! I'm Dr. Flask, Ph.D., and welcome to Corrosion Management.
|
||||
>
|
||||
> Running a game server is basically a controlled reaction. You need the right
|
||||
> environment, the right timing, the right ingredients, and a reliable way to know
|
||||
> what happened when things start bubbling.
|
||||
>
|
||||
> That's where Corrosion comes in.
|
||||
>
|
||||
> Catalyst Console is your mission control for servers, players, plugins, files,
|
||||
> wipes, schedules, and automation.
|
||||
>
|
||||
> re-Agent securely connects your server back to Catalyst.
|
||||
>
|
||||
> Substrate is the hardware your world runs on.
|
||||
>
|
||||
> Formulae are reusable recipes for deploying game servers.
|
||||
>
|
||||
> Reactions are the jobs that change server state — wipes, restarts, updates,
|
||||
> backups, and maintenance.
|
||||
>
|
||||
> Lab Notes show what happened, when it happened, and whether it worked.
|
||||
>
|
||||
> And The Exchange helps your community manage perks, packages, payments, and
|
||||
> in-game delivery.
|
||||
>
|
||||
> No chemistry degree required. Just better server management — with slightly more bubbling.
|
||||
|
||||
> **NOTE:** at natural pace this VO runs ~75–90s. For a true ≤60s Short, trim the
|
||||
> intro and one descriptor per term (see the glossary-video timing notes). For the
|
||||
> on-site FAQ embed, length is unconstrained.
|
||||
|
||||
---
|
||||
|
||||
*Domains are final: `corrosionmgmt.com` (company) + `panel.corrosionmgmt.com`
|
||||
(the panel = Catalyst Console). Brand handle mirrors the domain: CorrosionMgmt.*
|
||||
106
docs/character/drflask-character-bible.md
Normal file
106
docs/character/drflask-character-bible.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Dr. Flask — Character Bible
|
||||
|
||||
Corrosion's friendly chemistry guide. Appears in the FAQ and help sections to
|
||||
explain Corrosion's chemistry-themed lexicon without turning the panel into a
|
||||
chemistry class. Helpful? Yes. Mandatory? No. Likely bubbling with questionable
|
||||
enthusiasm? Absolutely.
|
||||
|
||||
**Definitive reference:** `drflask-v2final.webp` — the **v2 "90s spoof" model
|
||||
sheet** (current). `drflask-characterboard.png` is the v1 sheet (superseded).
|
||||
|
||||
## Identity (v2)
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ------------------------------ |
|
||||
| Name | Dr. Flask |
|
||||
| Alias | Corrosion Guide |
|
||||
| Title | Ph.D. (Self-Certified) |
|
||||
| Specialty | Server Chemistry |
|
||||
| Archetype | Helpful Guide |
|
||||
| Height | One Flask Tall |
|
||||
| Build | Erlenmeyer flask |
|
||||
| Liquid | Neon green |
|
||||
| Catchphrase| "No Degree Required" (degree not included) |
|
||||
|
||||
**Character note (from the v2 board):** *Appears whenever you need him.
|
||||
Sometimes when you don't. "It looks like you're managing a server. Want help?"
|
||||
No chemistry degree required.* Bubbles aggressively when excited.
|
||||
|
||||
**Comedic north star:** a loving spoof of the 90s helper-mascot era — Clippy +
|
||||
Jurassic Park's Mr. DNA — with Weird Al "White & Nerdy" self-aware nerd-pride.
|
||||
In on the joke, never the butt of it. He channels Clippy's *charm*, never
|
||||
Clippy's *intrusiveness* — the helper mascot we actually wanted (opt-in,
|
||||
dismissible, fun). Full voice guide: `docs/brand/brand-kit.md` §2.
|
||||
|
||||
## Color palette
|
||||
|
||||
Values as read from the model sheet — confirm exact hexes against the invideo
|
||||
source if pixel-accuracy matters.
|
||||
|
||||
| Swatch | Hex (approx) | Use |
|
||||
| --------------- | ------------ | -------------------------------- |
|
||||
| Neon Green | `#00FF3D` | The liquid — primary character color |
|
||||
| Tassel Green | `#39FF14` | Mortarboard tassel |
|
||||
| Bubble Highlight| `#B0FFB8` | Bubble/gesture highlights |
|
||||
| Glass | `#B6F7FF` | Flask glass / rim reflections |
|
||||
| Charcoal Gray | `#2A2A2A` | Cap, shadow |
|
||||
| Deep Black | `#0D0D0D` | Outline / background |
|
||||
|
||||
**In-product note:** the FAQ "lab zone" UI accent is a *readable* green
|
||||
(`--accent-text: #5bd183`, scoped to `.sec--lab`) — same family as the liquid
|
||||
but toned down so text/borders stay legible on dark (pure `#00FF3D` vibrates as
|
||||
UI text). Character art uses the neon greens above; UI uses the readable
|
||||
derivative. Can nudge the UI green brighter toward canon on request.
|
||||
|
||||
## Model sheet — animation reference (v2)
|
||||
|
||||
- **Views:** 3/4 view, side view.
|
||||
- **Expression progression (8):** neutral · excited · dramatic · offended ·
|
||||
conspiratorial · triumphant · worried · thumbs-up.
|
||||
- **Micro-expressions (5):** liquid rises · eyebrow arch · mortarboard tilt ·
|
||||
toothy grin · eyes narrow.
|
||||
- **Posture variations (4):** arms-wide welcoming · leaning on pointer stick ·
|
||||
pointing dramatically · celebratory bounce.
|
||||
- **Hand gestures (white cartoon gloves):** finger-gun pointing · double
|
||||
thumbs-up · one hand raised.
|
||||
- **Silhouettes:** neutral, action.
|
||||
- **Wardrobe (v2 — current):** mortarboard worn **askew** + green tassel · **bow
|
||||
tie** (yellow) · **lab coat** · **pointer stick** · clip-on microphone ·
|
||||
**googly eyes**. Energy = Clippy's persistence + Mr. DNA's flair + Weird Al's
|
||||
chaotic sincerity. (v1 was a clean kawaii render with just the mortarboard.)
|
||||
- **Added palette (v2):** Bow Tie Yellow · Lab Coat White (atop the green/charcoal core).
|
||||
|
||||
## Storyboard (12-beat video sequence)
|
||||
|
||||
`drflask-storyboard.webp` — maps panel-for-panel to the VO script:
|
||||
hero intro · server world · Catalyst (mission control) · console/analytics ·
|
||||
re-Agent (plugged-in shield, no inbound ports) · Substrate (server racks) ·
|
||||
Formulae (recipe book) · Reactions (data wave) · Compounds (service cluster) ·
|
||||
Lab Notes (clipboard) · The Exchange (marketplace grid) · outro wave.
|
||||
|
||||
## Where he appears
|
||||
|
||||
- **FAQ chemistry glossary** (`frontend/src/views/marketing/FaqView.vue`,
|
||||
`#chemistry`): the cover card beside the "Brush up on your chemistry…" heading.
|
||||
- **Intro video:** 75–90s, 9:16 vertical (YouTube Short) explainer — Dr. Flask
|
||||
reads the glossary. Plays click-to-play in a **phone-frame lightbox** (no loop,
|
||||
controls at the bottom of the screen). See `phone-frame-preview.png`.
|
||||
|
||||
## Assets
|
||||
|
||||
| File | What |
|
||||
| ----------------------------------------- | ------------------------------------------ |
|
||||
| `drflask-v2final.webp` | **Model sheet — v2 (current, definitive)** |
|
||||
| `drflask-characterboard.png` | Model sheet — v1 (superseded) |
|
||||
| `drflask-storyboard.webp` | 12-beat video storyboard (invideo) |
|
||||
| `drflask-final.png` | Placeholder card render (1254², source) |
|
||||
| `theflask.png` / `theatom.png` | Earlier concept cards |
|
||||
| `frontend/src/assets/mascots/drflask.png` | Web-optimized cover (560px, ~394 KB) |
|
||||
| `phone-frame-preview.png` | Preview of the phone-frame lightbox |
|
||||
|
||||
## Status
|
||||
|
||||
Placeholder card art (ChatGPT) in use on the FAQ; full animated character +
|
||||
75–90s intro video in production via invideo (Gemini-scripted), now backed by
|
||||
the model sheet above. Swap the cover + wire the video into the lightbox when
|
||||
the render lands.
|
||||
BIN
docs/character/drflask-characterboard.png
Normal file
BIN
docs/character/drflask-characterboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
BIN
docs/character/drflask-final.png
Normal file
BIN
docs/character/drflask-final.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/character/drflask-storyboard.webp
Normal file
BIN
docs/character/drflask-storyboard.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 756 KiB |
BIN
docs/character/drflask-v2final.webp
Normal file
BIN
docs/character/drflask-v2final.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -8,7 +8,7 @@
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<title>Corrosion Management</title>
|
||||
<title>Catalyst Console</title>
|
||||
<meta name="description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />
|
||||
<meta property="og:title" content="Corrosion — Game Server Operations for Self-Hosted Communities" />
|
||||
<meta property="og:description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />
|
||||
|
||||
BIN
frontend/src/assets/mascots/drflask-intro.mp4
Normal file
BIN
frontend/src/assets/mascots/drflask-intro.mp4
Normal file
Binary file not shown.
BIN
frontend/src/assets/mascots/drflask-poster.jpg
Normal file
BIN
frontend/src/assets/mascots/drflask-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/src/assets/mascots/drflask.png
Normal file
BIN
frontend/src/assets/mascots/drflask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Logo — Corrosion brand lockup.
|
||||
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
|
||||
* Logo — Catalyst brand lockup.
|
||||
* Composes the CorrosionMark SVG + Oxanium wordmark "Catalyst" + optional tagline.
|
||||
*
|
||||
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
||||
* parent (or pass `markColor`) to theme it per active game.
|
||||
*
|
||||
* Props mirror Logo.jsx exactly:
|
||||
* size — base px size; drives mark em-size + wordmark scaling
|
||||
* wordmark — show the "Corrosion" text (default true)
|
||||
* tagline — false | true (→ "Management Panel") | custom string
|
||||
* wordmark — show the "Catalyst" text (default true)
|
||||
* tagline — false | true (→ "by Corrosion") | custom string
|
||||
* glow — accent drop-shadow for marketing / login hero use
|
||||
* markColor — force a fixed color on the mark (bypasses currentColor theming)
|
||||
*/
|
||||
@@ -35,7 +35,7 @@ const glowFilter = computed(() =>
|
||||
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
|
||||
)
|
||||
const tagText = computed(() =>
|
||||
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
|
||||
typeof props.tagline === 'string' ? props.tagline : 'by Corrosion'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -70,7 +70,7 @@ const tagText = computed(() =>
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1,
|
||||
}"
|
||||
>Corrosion</span>
|
||||
>Catalyst</span>
|
||||
<span
|
||||
v-if="tagline"
|
||||
:style="{
|
||||
|
||||
@@ -126,7 +126,7 @@ const agentLabel = computed(() => {
|
||||
})
|
||||
// One host → its hostname; multiple → fleet count.
|
||||
const agentName = computed(() =>
|
||||
hostCount.value === 1 ? (realHosts.value[0]?.hostname ?? 'Host agent') : `${hostCount.value} hosts`,
|
||||
hostCount.value === 1 ? (realHosts.value[0]?.hostname ?? 're-Agent') : `${hostCount.value} hosts`,
|
||||
)
|
||||
|
||||
const agentMetaLine = computed(() => {
|
||||
@@ -231,9 +231,9 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||
<div v-else class="agent agent--empty">
|
||||
<div class="agent__row">
|
||||
<StatusDot tone="offline" />
|
||||
<span class="agent__name agent__name--muted">No host agent connected</span>
|
||||
<span class="agent__name agent__name--muted">No re-Agent connected</span>
|
||||
</div>
|
||||
<div class="agent__meta">Install the Corrosion host agent from the Server page</div>
|
||||
<div class="agent__meta">Install re-Agent from the Server page</div>
|
||||
</div>
|
||||
<!-- User / logout row -->
|
||||
<div class="side__user">
|
||||
@@ -272,7 +272,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div class="top__crumbs">
|
||||
<span class="crumb">Corrosion</span>
|
||||
<span class="crumb">Catalyst</span>
|
||||
<span class="crumb__sep">/</span>
|
||||
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ function applyActiveGame(g: ActiveGame, persist: boolean): void {
|
||||
/**
|
||||
* Derive the active game from the deployed fleet — the game instances are the
|
||||
* source of truth for which game(s) a license runs (game_instances.game, set by
|
||||
* the host agent). Exactly one game deployed → skin the shell to it; zero or
|
||||
* re-Agent). Exactly one game deployed → skin the shell to it; zero or
|
||||
* multiple → 'all' (neutral house skin).
|
||||
*
|
||||
* NO-OP when the operator has a manual pick stored (cc-active-game present): an
|
||||
|
||||
@@ -52,7 +52,7 @@ const marketingChildren: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/marketing/HowItWorksView.vue'),
|
||||
meta: {
|
||||
title: 'How It Works — Corrosion',
|
||||
description: 'Install one host agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
|
||||
description: 'Install one re-Agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@ const marketingChildren: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/marketing/RoadmapView.vue'),
|
||||
meta: {
|
||||
title: 'Roadmap — Corrosion',
|
||||
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game blueprints. Planned: API access, integrations.',
|
||||
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game Formulae. Planned: API access, integrations.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -94,25 +94,25 @@ const panelRoutes: RouteRecordRaw[] = [
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { guest: true, title: 'Sign in — Corrosion' },
|
||||
meta: { guest: true, title: 'Sign in to Catalyst' },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { guest: true, title: 'Create account — Corrosion' },
|
||||
meta: { guest: true, title: 'Create account — Catalyst' },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
meta: { guest: true, title: 'Reset password — Corrosion' },
|
||||
meta: { guest: true, title: 'Reset password — Catalyst' },
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup-wizard',
|
||||
component: () => import('@/views/auth/SetupWizardView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
|
||||
meta: { requiresAuth: true, title: 'Setup — Catalyst' },
|
||||
},
|
||||
|
||||
// Admin dashboard routes (with sidebar layout)
|
||||
@@ -125,260 +125,260 @@ const panelRoutes: RouteRecordRaw[] = [
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: { title: 'Dashboard — Corrosion' },
|
||||
meta: { title: 'Dashboard · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'server',
|
||||
name: 'server',
|
||||
component: () => import('@/views/admin/ServerView.vue'),
|
||||
meta: { title: 'Server — Corrosion' },
|
||||
meta: { title: 'Server · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'console',
|
||||
name: 'console',
|
||||
component: () => import('@/views/admin/ConsoleView.vue'),
|
||||
meta: { title: 'Console — Corrosion' },
|
||||
meta: { title: 'Console · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'players',
|
||||
name: 'players',
|
||||
component: () => import('@/views/admin/PlayersView.vue'),
|
||||
meta: { title: 'Players — Corrosion' },
|
||||
meta: { title: 'Players · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('@/views/admin/PluginsView.vue'),
|
||||
meta: { title: 'Plugins — Corrosion' },
|
||||
meta: { title: 'Plugins · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'files',
|
||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||
meta: { title: 'Files — Corrosion' },
|
||||
meta: { title: 'Files · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'plugin-configs',
|
||||
name: 'plugin-configs',
|
||||
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
||||
meta: { title: 'Plugin Configs — Corrosion' },
|
||||
meta: { title: 'Plugin Configs · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'loot-builder',
|
||||
name: 'loot-builder',
|
||||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||
meta: { title: 'Loot Builder — Corrosion' },
|
||||
meta: { title: 'Loot Builder · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'teleport-config',
|
||||
name: 'teleport-config',
|
||||
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||||
meta: { title: 'Teleport Config — Corrosion' },
|
||||
meta: { title: 'Teleport Config · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'gather-manager',
|
||||
name: 'gather-manager',
|
||||
component: () => import('@/views/admin/GatherManagerView.vue'),
|
||||
meta: { title: 'Gather Manager — Corrosion' },
|
||||
meta: { title: 'Gather Manager · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'autodoors',
|
||||
name: 'autodoors',
|
||||
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
||||
meta: { title: 'Auto Doors — Corrosion' },
|
||||
meta: { title: 'Auto Doors · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'kits',
|
||||
name: 'kits-config',
|
||||
component: () => import('@/views/admin/KitsView.vue'),
|
||||
meta: { title: 'Kits — Corrosion' },
|
||||
meta: { title: 'Kits · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'furnace-splitter',
|
||||
name: 'furnace-splitter',
|
||||
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
||||
meta: { title: 'Furnace Splitter — Corrosion' },
|
||||
meta: { title: 'Furnace Splitter · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'better-chat',
|
||||
name: 'better-chat',
|
||||
component: () => import('@/views/admin/BetterChatView.vue'),
|
||||
meta: { title: 'Better Chat — Corrosion' },
|
||||
meta: { title: 'Better Chat · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'timed-execute',
|
||||
name: 'timed-execute',
|
||||
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
||||
meta: { title: 'Timed Execute — Corrosion' },
|
||||
meta: { title: 'Timed Execute · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'raidable-bases',
|
||||
name: 'raidable-bases',
|
||||
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
||||
meta: { title: 'Raidable Bases — Corrosion' },
|
||||
meta: { title: 'Raidable Bases · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'wipes',
|
||||
name: 'wipes',
|
||||
component: () => import('@/views/admin/WipesView.vue'),
|
||||
meta: { title: 'Wipes — Corrosion' },
|
||||
meta: { title: 'Wipes · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/profiles',
|
||||
name: 'wipe-profiles',
|
||||
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
||||
meta: { title: 'Wipe Profiles — Corrosion' },
|
||||
meta: { title: 'Wipe Profiles · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/calendar',
|
||||
name: 'wipe-calendar',
|
||||
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
||||
meta: { title: 'Wipe Calendar — Corrosion' },
|
||||
meta: { title: 'Wipe Calendar · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/history',
|
||||
name: 'wipe-history',
|
||||
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
||||
meta: { title: 'Wipe History — Corrosion' },
|
||||
meta: { title: 'Wipe History · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/analytics',
|
||||
name: 'wipe-analytics',
|
||||
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
||||
meta: { title: 'Wipe Analytics — Corrosion' },
|
||||
meta: { title: 'Wipe Analytics · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'maps',
|
||||
name: 'maps',
|
||||
component: () => import('@/views/admin/MapsView.vue'),
|
||||
meta: { title: 'Maps — Corrosion' },
|
||||
meta: { title: 'Maps · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'maps/analytics',
|
||||
name: 'map-analytics',
|
||||
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
||||
meta: { title: 'Map Analytics — Corrosion' },
|
||||
meta: { title: 'Map Analytics · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
name: 'chat',
|
||||
component: () => import('@/views/admin/ChatLogView.vue'),
|
||||
meta: { title: 'Chat Log — Corrosion' },
|
||||
meta: { title: 'Chat Log · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'analytics',
|
||||
name: 'analytics',
|
||||
component: () => import('@/views/admin/AnalyticsView.vue'),
|
||||
meta: { title: 'Analytics — Corrosion' },
|
||||
meta: { title: 'Analytics · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'retention',
|
||||
name: 'retention',
|
||||
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
||||
meta: { title: 'Player Retention — Corrosion' },
|
||||
meta: { title: 'Player Retention · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('@/views/admin/NotificationsView.vue'),
|
||||
meta: { title: 'Notifications — Corrosion' },
|
||||
meta: { title: 'Notifications · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
name: 'team',
|
||||
component: () => import('@/views/admin/TeamView.vue'),
|
||||
meta: { title: 'Team — Corrosion' },
|
||||
meta: { title: 'Team · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'store/config',
|
||||
name: 'store-config',
|
||||
component: () => import('@/views/admin/StoreConfigView.vue'),
|
||||
meta: { title: 'Store Config — Corrosion' },
|
||||
meta: { title: 'Store Config · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'store/items',
|
||||
name: 'store-items',
|
||||
component: () => import('@/views/admin/StoreItemsView.vue'),
|
||||
meta: { title: 'Store Items — Corrosion' },
|
||||
meta: { title: 'Store Items · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'store/revenue',
|
||||
name: 'store-revenue',
|
||||
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
||||
meta: { title: 'Store Revenue — Corrosion' },
|
||||
meta: { title: 'Store Revenue · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'modules',
|
||||
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
||||
meta: { title: 'Modules — Corrosion' },
|
||||
meta: { title: 'Modules · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/admin/SettingsView.vue'),
|
||||
meta: { title: 'Settings — Corrosion' },
|
||||
meta: { title: 'Settings · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'schedules',
|
||||
name: 'schedules',
|
||||
component: () => import('@/views/admin/SchedulesView.vue'),
|
||||
meta: { title: 'Schedules — Corrosion' },
|
||||
meta: { title: 'Schedules · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'migration',
|
||||
name: 'migration',
|
||||
component: () => import('@/views/admin/MigrationView.vue'),
|
||||
meta: { title: 'Migration — Corrosion' },
|
||||
meta: { title: 'Migration · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
name: 'changelog',
|
||||
component: () => import('@/views/admin/ChangelogView.vue'),
|
||||
meta: { title: 'Changelog — Corrosion' },
|
||||
meta: { title: 'Changelog · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'alerts',
|
||||
name: 'alerts',
|
||||
component: () => import('@/views/admin/AlertsView.vue'),
|
||||
meta: { title: 'Alerts — Corrosion' },
|
||||
meta: { title: 'Alerts · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'fleet',
|
||||
name: 'fleet',
|
||||
component: () => import('@/views/admin/FleetView.vue'),
|
||||
meta: { title: 'Fleet — Corrosion', requiresAuth: true },
|
||||
meta: { title: 'Fleet · Catalyst', requiresAuth: true },
|
||||
},
|
||||
// Platform Admin views (super-admin only)
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'platform-admin',
|
||||
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||||
meta: { superAdmin: true, title: 'Admin — Corrosion' },
|
||||
meta: { superAdmin: true, title: 'Admin · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'admin/licenses',
|
||||
name: 'platform-licenses',
|
||||
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||||
meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
|
||||
meta: { superAdmin: true, title: 'Admin: Licenses · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'admin/subscriptions',
|
||||
name: 'platform-subscriptions',
|
||||
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||||
meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
|
||||
meta: { superAdmin: true, title: 'Admin: Subscriptions · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
name: 'platform-users',
|
||||
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||||
meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
|
||||
meta: { superAdmin: true, title: 'Admin: Users · Catalyst' },
|
||||
},
|
||||
{
|
||||
path: 'admin/servers',
|
||||
name: 'platform-servers',
|
||||
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
||||
meta: { superAdmin: true, title: 'Admin: Servers — Corrosion' },
|
||||
meta: { superAdmin: true, title: 'Admin: Servers · Catalyst' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -413,7 +413,7 @@ const panelRoutes: RouteRecordRaw[] = [
|
||||
path: '/status',
|
||||
name: 'status',
|
||||
component: () => import('@/views/public/StatusPageView.vue'),
|
||||
meta: { title: 'Status — Corrosion' },
|
||||
meta: { title: 'Status · Catalyst' },
|
||||
},
|
||||
|
||||
// Catch-all
|
||||
@@ -488,14 +488,14 @@ function setOrClearMeta(selector: string, attr: string, value: string): void {
|
||||
|
||||
router.afterEach((to) => {
|
||||
// Title
|
||||
document.title = to.meta.title ?? 'Corrosion Management'
|
||||
document.title = to.meta.title ?? 'Catalyst Console'
|
||||
|
||||
// Description
|
||||
const desc = to.meta.description ?? ''
|
||||
setOrClearMeta('meta[name="description"]', 'content', desc)
|
||||
|
||||
// OG title
|
||||
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Corrosion Management')
|
||||
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Catalyst Console')
|
||||
|
||||
// OG description
|
||||
setOrClearMeta('meta[property="og:description"]', 'content', desc)
|
||||
|
||||
@@ -97,7 +97,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
? decodeJwtPermissions(accessToken.value)
|
||||
: {}
|
||||
|
||||
return perms[permission] === true
|
||||
// Honor the global wildcard (Owner) and resource wildcards ("server.*")
|
||||
// so role permissions stored as wildcards aren't missed by an exact match.
|
||||
if (perms['*'] === true) return true
|
||||
if (perms[permission] === true) return true
|
||||
const resourceWildcard = permission.split('.')[0] + '.*'
|
||||
return perms[resourceWildcard] === true
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -39,7 +39,7 @@ export const useFilesStore = defineStore('files', () => {
|
||||
async function list(path: string): Promise<void> {
|
||||
const id = currentId()
|
||||
if (!id) {
|
||||
error.value = 'No instance — connect the host agent'
|
||||
error.value = 'No instance — connect re-Agent'
|
||||
entries.value = []
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function handleWebSocketMessage(message: WebSocketMessage) {
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
addLine('Corrosion console initialized.', 'system')
|
||||
addLine('Catalyst Console initialized.', 'system')
|
||||
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||
if (server.connection?.connection_status !== 'connected') {
|
||||
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
||||
|
||||
@@ -259,7 +259,7 @@ function navServer() { router.push('/server') }
|
||||
<EmptyState
|
||||
icon="server"
|
||||
title="No server connected"
|
||||
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
|
||||
description="Install re-Agent on your host machine to begin managing your server from Catalyst."
|
||||
>
|
||||
<template #action>
|
||||
<Button icon="server" @click="navServer">Set up server</Button>
|
||||
@@ -404,7 +404,7 @@ function navServer() { router.push('/server') }
|
||||
<div class="dash__col dash__col--side">
|
||||
|
||||
<!-- Resources — real stats from agent; null = '—' -->
|
||||
<Panel title="Resources" subtitle="Host agent telemetry">
|
||||
<Panel title="Resources" subtitle="re-Agent telemetry">
|
||||
<div class="solo-meters">
|
||||
<ResourceMeter
|
||||
label="CPU"
|
||||
@@ -418,7 +418,7 @@ function navServer() { router.push('/server') }
|
||||
/>
|
||||
</div>
|
||||
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
||||
Resource metrics arrive via the host agent heartbeat.
|
||||
Resource metrics arrive via re-Agent heartbeat.
|
||||
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
||||
Agent setup
|
||||
</Button>
|
||||
|
||||
@@ -193,8 +193,8 @@ async function confirmDelete(path: string) {
|
||||
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
|
||||
<EmptyState
|
||||
icon="server"
|
||||
title="No host agent connected"
|
||||
description="Install the host agent from the Server page to manage files on your game server."
|
||||
title="No re-Agent connected"
|
||||
description="Install re-Agent from the Server page to manage files on your game server."
|
||||
>
|
||||
<template #action>
|
||||
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
|
||||
|
||||
@@ -154,7 +154,7 @@ function relativeHeartbeat(iso: string | null): string {
|
||||
<EmptyState
|
||||
icon="server"
|
||||
title="No hosts connected yet"
|
||||
description="Install the Corrosion host agent on your server machine to see it here."
|
||||
description="Install re-Agent on your server machine to see it here."
|
||||
>
|
||||
<template #action>
|
||||
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>
|
||||
|
||||
@@ -52,7 +52,7 @@ const tabItems = [
|
||||
function sourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'umod': return 'uMod'
|
||||
case 'corrosion_module': return 'Corrosion'
|
||||
case 'corrosion_module': return 'Catalyst'
|
||||
case 'manual': return 'Manual'
|
||||
default: return source
|
||||
}
|
||||
@@ -485,7 +485,7 @@ onMounted(() => {
|
||||
</Panel>
|
||||
|
||||
<Alert tone="info">
|
||||
The plugin will be registered in your plugin list immediately. Your host agent must be connected
|
||||
The plugin will be registered in your plugin list immediately. Your re-Agent must be connected
|
||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
@@ -327,7 +327,7 @@ async function saveConfig() {
|
||||
|
||||
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||
if (!currentInstance.value) {
|
||||
toast.error('No game instance to control — connect the host agent first')
|
||||
toast.error('No game instance to control — connect re-Agent first')
|
||||
return
|
||||
}
|
||||
actionLoading.value = action
|
||||
@@ -532,7 +532,7 @@ onMounted(async () => {
|
||||
v-if="!currentInstance"
|
||||
icon="server"
|
||||
title="No game instance connected"
|
||||
:description="'Install the host agent and add a ' + profile.label + ' instance to its config to manage it here.'"
|
||||
:description="'Install re-Agent and add a ' + profile.label + ' instance to its config to manage it here.'"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
@@ -611,7 +611,7 @@ onMounted(async () => {
|
||||
</Panel>
|
||||
|
||||
<!-- Host agent -->
|
||||
<Panel title="Host agent" subtitle="Bare-metal server management binary">
|
||||
<Panel title="re-Agent" subtitle="Bare-metal server management binary">
|
||||
<template #actions>
|
||||
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
||||
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
||||
@@ -640,7 +640,7 @@ onMounted(async () => {
|
||||
<!-- Download -->
|
||||
<div class="sv__section-head">
|
||||
<Icon name="download" :size="14" />
|
||||
<span>Download host agent</span>
|
||||
<span>Download re-Agent</span>
|
||||
</div>
|
||||
<div class="sv__downloads sv__mb">
|
||||
<a
|
||||
@@ -683,7 +683,7 @@ onMounted(async () => {
|
||||
|
||||
<!-- Linux commands -->
|
||||
<div v-if="setupTab === 'linux'" class="sv__codeblock">
|
||||
<p class="sv__cmt"># Download the agent</p>
|
||||
<p class="sv__cmt"># Download re-Agent</p>
|
||||
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||
<p>chmod +x corrosion-host-agent-linux-amd64</p>
|
||||
<p class="sv__cmt sv__mt"># Write /etc/corrosion/agent.toml (see config block below), then run:</p>
|
||||
@@ -694,7 +694,7 @@ onMounted(async () => {
|
||||
<!-- Windows commands -->
|
||||
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
||||
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
||||
<p class="sv__cmt"># Download the agent</p>
|
||||
<p class="sv__cmt"># Download re-Agent</p>
|
||||
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
|
||||
<p class="sv__cmt sv__mt"># Write C:\ProgramData\Corrosion\agent.toml (see config block below), then run:</p>
|
||||
<p>New-Item -ItemType Directory -Force -Path <span class="sv__accent">"C:\ProgramData\Corrosion"</span></p>
|
||||
@@ -726,7 +726,7 @@ onMounted(async () => {
|
||||
<pre class="sv__pre">{{ agentTomlConfig }}</pre>
|
||||
</div>
|
||||
<Alert v-if="!agentCreds" tone="warn" class="sv__mt">
|
||||
Could not load credentials from server. Copy this config and replace the placeholders with values from your Corrosion dashboard settings.
|
||||
Could not load credentials from server. Copy this config and replace the placeholders with values from your Catalyst dashboard settings.
|
||||
</Alert>
|
||||
</Panel>
|
||||
|
||||
@@ -858,7 +858,7 @@ onMounted(async () => {
|
||||
<EmptyState
|
||||
icon="box"
|
||||
title="Docker-managed deployment"
|
||||
:description="profile.label + ' servers are managed via Docker Compose. Connect the host agent on your Docker host to enable lifecycle management.'"
|
||||
:description="profile.label + ' servers are managed via Docker Compose. Connect re-Agent on your Docker host to enable lifecycle management.'"
|
||||
>
|
||||
<template #action>
|
||||
<Badge tone="info">Docker · Compose</Badge>
|
||||
@@ -929,7 +929,7 @@ onMounted(async () => {
|
||||
<EmptyState
|
||||
icon="layers"
|
||||
:title="profile.label + ' mod management'"
|
||||
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Corrosion install step needed.'"
|
||||
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Catalyst install step needed.'"
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
@@ -973,7 +973,7 @@ onMounted(async () => {
|
||||
<EmptyState
|
||||
icon="network"
|
||||
title="Cluster management coming soon"
|
||||
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires the host agent.'"
|
||||
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires re-Agent.'"
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
@@ -986,7 +986,7 @@ onMounted(async () => {
|
||||
<EmptyState
|
||||
icon="map"
|
||||
title="Sietch management requires a connected Dune host"
|
||||
description="Connect the host agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
|
||||
description="Connect re-Agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -33,6 +33,31 @@ const publicSiteForm = ref({
|
||||
status_page_description: '',
|
||||
})
|
||||
|
||||
// --- Security: password change ---
|
||||
const pwForm = ref({ current_password: '', new_password: '', confirm: '' })
|
||||
const changingPw = ref(false)
|
||||
|
||||
// --- Security: 2FA enrollment flow ---
|
||||
const totp = ref<{ qr: string; secret: string; code: string; setting: boolean }>({
|
||||
qr: '', secret: '', code: '', setting: false,
|
||||
})
|
||||
const disable2fa = ref({ open: false, code: '', busy: false })
|
||||
|
||||
// --- API keys ---
|
||||
interface ApiKeyRow {
|
||||
id: string
|
||||
name: string
|
||||
key_prefix: string
|
||||
last_used_at: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
const apiKeys = ref<ApiKeyRow[]>([])
|
||||
const newKeyName = ref('')
|
||||
const creatingKey = ref(false)
|
||||
const createdKey = ref<string | null>(null)
|
||||
const loadingKeys = ref(false)
|
||||
|
||||
async function loadForms() {
|
||||
if (auth.user) {
|
||||
accountForm.value.username = auth.user.username
|
||||
@@ -89,16 +114,144 @@ async function savePublicSite() {
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (pwForm.value.new_password.length < 8) {
|
||||
toast.error('New password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
if (pwForm.value.new_password !== pwForm.value.confirm) {
|
||||
toast.error('New password and confirmation do not match')
|
||||
return
|
||||
}
|
||||
changingPw.value = true
|
||||
try {
|
||||
await api.post('/auth/change-password', {
|
||||
current_password: pwForm.value.current_password,
|
||||
new_password: pwForm.value.new_password,
|
||||
})
|
||||
toast.success('Password changed')
|
||||
pwForm.value = { current_password: '', new_password: '', confirm: '' }
|
||||
} catch (err) {
|
||||
toast.error('Failed to change password: ' + (err as Error).message)
|
||||
} finally {
|
||||
changingPw.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startTotpSetup() {
|
||||
totp.value.setting = true
|
||||
try {
|
||||
const res = await api.post<{ qr_code: string; secret: string }>('/auth/2fa/setup', {})
|
||||
totp.value.qr = res.qr_code
|
||||
totp.value.secret = res.secret
|
||||
} catch (err) {
|
||||
totp.value.setting = false
|
||||
toast.error('Failed to start 2FA setup: ' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotpSetup() {
|
||||
if (totp.value.code.length !== 6) {
|
||||
toast.error('Enter the 6-digit code from your authenticator')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.post('/auth/2fa/verify', { code: totp.value.code })
|
||||
await auth.validateSession()
|
||||
toast.success('Two-factor authentication enabled')
|
||||
totp.value = { qr: '', secret: '', code: '', setting: false }
|
||||
} catch (err) {
|
||||
toast.error('Invalid code — try again: ' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTotpSetup() {
|
||||
totp.value = { qr: '', secret: '', code: '', setting: false }
|
||||
}
|
||||
|
||||
async function confirmDisable2fa() {
|
||||
if (disable2fa.value.code.length !== 6) {
|
||||
toast.error('Enter your current 6-digit code to disable 2FA')
|
||||
return
|
||||
}
|
||||
disable2fa.value.busy = true
|
||||
try {
|
||||
await api.post('/auth/2fa/disable', { code: disable2fa.value.code })
|
||||
await auth.validateSession()
|
||||
toast.success('Two-factor authentication disabled')
|
||||
disable2fa.value = { open: false, code: '', busy: false }
|
||||
} catch (err) {
|
||||
toast.error('Failed to disable 2FA: ' + (err as Error).message)
|
||||
} finally {
|
||||
disable2fa.value.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
loadingKeys.value = true
|
||||
try {
|
||||
apiKeys.value = await api.get<ApiKeyRow[]>('/api-keys')
|
||||
} catch (err) {
|
||||
toast.error('Failed to load API keys: ' + (err as Error).message)
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newKeyName.value.trim()) {
|
||||
toast.error('Give the key a name')
|
||||
return
|
||||
}
|
||||
creatingKey.value = true
|
||||
createdKey.value = null
|
||||
try {
|
||||
const res = await api.post<{ plaintext_key: string }>('/api-keys', {
|
||||
name: newKeyName.value.trim(),
|
||||
})
|
||||
createdKey.value = res.plaintext_key
|
||||
newKeyName.value = ''
|
||||
await loadApiKeys()
|
||||
} catch (err) {
|
||||
toast.error('Failed to create API key: ' + (err as Error).message)
|
||||
} finally {
|
||||
creatingKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeApiKey(id: string) {
|
||||
try {
|
||||
await api.del(`/api-keys/${id}`)
|
||||
toast.success('API key revoked')
|
||||
await loadApiKeys()
|
||||
} catch (err) {
|
||||
toast.error('Failed to revoke key: ' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function copyKey(value: string) {
|
||||
navigator.clipboard?.writeText(value)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadForms()
|
||||
loadApiKeys()
|
||||
})
|
||||
|
||||
const tabItems = [
|
||||
{ value: 'account', label: 'Account', icon: 'user' },
|
||||
{ value: 'security', label: 'Security', icon: 'shield' },
|
||||
{ value: 'api', label: 'API', icon: 'code' },
|
||||
{ value: 'license', label: 'License', icon: 'key' },
|
||||
{ value: 'domain', label: 'Domain', icon: 'globe' },
|
||||
{ value: 'public', label: 'Public status', icon: 'eye' },
|
||||
]
|
||||
|
||||
const swaggerUrl = '/api/docs'
|
||||
function openApiDocs() {
|
||||
window.open(swaggerUrl, '_blank', 'noopener')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -142,6 +295,120 @@ const tabItems = [
|
||||
>
|
||||
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
||||
</Badge>
|
||||
<span class="totp-hint">Manage in the Security tab</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Security -->
|
||||
<Panel v-if="section === 'security'" title="Security" eyebrow="Password & 2FA">
|
||||
<div class="sec-stack">
|
||||
<!-- Change password -->
|
||||
<div class="sec-block">
|
||||
<h3 class="sec-title">Change password</h3>
|
||||
<div class="pw-grid">
|
||||
<Input v-model="pwForm.current_password" type="password" label="Current password" placeholder="••••••••" />
|
||||
<Input v-model="pwForm.new_password" type="password" label="New password" placeholder="At least 8 characters" />
|
||||
<Input v-model="pwForm.confirm" type="password" label="Confirm new password" placeholder="Re-enter new password" />
|
||||
</div>
|
||||
<Button size="sm" icon="save" :loading="changingPw" @click="changePassword">Update password</Button>
|
||||
</div>
|
||||
|
||||
<!-- Two-factor authentication -->
|
||||
<div class="sec-block">
|
||||
<div class="sec-head">
|
||||
<h3 class="sec-title">Two-factor authentication</h3>
|
||||
<Badge :tone="auth.user?.totp_enabled ? 'online' : 'warn'" :dot="true">
|
||||
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Enabled -> offer disable -->
|
||||
<template v-if="auth.user?.totp_enabled">
|
||||
<p class="sec-note">Your account is protected with an authenticator app.</p>
|
||||
<Button v-if="!disable2fa.open" size="sm" variant="danger" icon="shield-off" @click="disable2fa.open = true">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
<div v-else class="twofa-confirm">
|
||||
<p class="sec-note">Enter your current 6-digit code to confirm.</p>
|
||||
<Input v-model="disable2fa.code" label="Authenticator code" placeholder="123456" />
|
||||
<div class="btn-row">
|
||||
<Button size="sm" variant="danger" :loading="disable2fa.busy" @click="confirmDisable2fa">Confirm disable</Button>
|
||||
<Button size="sm" variant="ghost" @click="disable2fa.open = false">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Not enabled -> enrollment -->
|
||||
<template v-else>
|
||||
<p class="sec-note">Add a second factor with an authenticator app (Google Authenticator, Authy, 1Password).</p>
|
||||
<Button v-if="!totp.setting" size="sm" icon="shield" @click="startTotpSetup">Enable 2FA</Button>
|
||||
<div v-else class="twofa-enroll">
|
||||
<div v-if="totp.qr" class="qr-wrap">
|
||||
<img :src="totp.qr" alt="2FA QR code" class="qr-img" />
|
||||
<div class="qr-side">
|
||||
<p class="sec-note">Scan with your authenticator app, or enter the secret manually:</p>
|
||||
<code class="secret">{{ totp.secret }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<Input v-model="totp.code" label="Enter the 6-digit code to confirm" placeholder="123456" />
|
||||
<div class="btn-row">
|
||||
<Button size="sm" icon="check" @click="confirmTotpSetup">Verify & enable</Button>
|
||||
<Button size="sm" variant="ghost" @click="cancelTotpSetup">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- API access -->
|
||||
<Panel v-if="section === 'api'" title="API access" eyebrow="Programmatic">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="secondary" icon="book-open" icon-right="external-link" @click="openApiDocs">API docs</Button>
|
||||
</template>
|
||||
<div class="api-stack">
|
||||
<p class="section-note">
|
||||
Create a key to call the Corrosion REST API from your own tooling. Send it as
|
||||
<code class="inline-code">Authorization: Bearer corr_…</code> — a key acts as the license owner.
|
||||
The full key is shown once at creation.
|
||||
</p>
|
||||
|
||||
<!-- Create -->
|
||||
<div class="key-create">
|
||||
<Input v-model="newKeyName" label="New key name" placeholder="e.g. CI deploy, monitoring" style="flex:1" />
|
||||
<Button size="sm" icon="plus" :loading="creatingKey" @click="createApiKey">Create key</Button>
|
||||
</div>
|
||||
|
||||
<!-- Just-created plaintext key (shown once) -->
|
||||
<div v-if="createdKey" class="key-reveal">
|
||||
<div class="key-reveal__head">
|
||||
<span class="field-label">Copy your key now — it won't be shown again</span>
|
||||
<Button size="sm" variant="ghost" icon="copy" @click="copyKey(createdKey!)">Copy</Button>
|
||||
</div>
|
||||
<code class="key-reveal__value">{{ createdKey }}</code>
|
||||
</div>
|
||||
|
||||
<!-- Existing keys -->
|
||||
<div v-if="loadingKeys" class="key-empty">Loading…</div>
|
||||
<div v-else-if="apiKeys.length === 0" class="key-empty">No API keys yet.</div>
|
||||
<table v-else class="key-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Prefix</th><th>Last used</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="k in apiKeys" :key="k.id">
|
||||
<td>{{ k.name }}</td>
|
||||
<td><code class="field-mono">corr_{{ k.key_prefix }}…</code></td>
|
||||
<td>{{ k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'Never' }}</td>
|
||||
<td>
|
||||
<Badge :tone="k.is_active ? 'online' : 'offline'">{{ k.is_active ? 'Active' : 'Revoked' }}</Badge>
|
||||
</td>
|
||||
<td class="key-actions">
|
||||
<Button v-if="k.is_active" size="sm" variant="danger-soft" icon="trash-2" @click="revokeApiKey(k.id)">Revoke</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -309,8 +576,44 @@ const tabItems = [
|
||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
|
||||
.totp-hint { font-size: var(--text-xs); color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
/* Security tab */
|
||||
.sec-stack { display: flex; flex-direction: column; gap: 22px; }
|
||||
.sec-block { display: flex; flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||
.sec-block + .sec-block { padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||
.sec-head { display: flex; align-items: center; gap: 10px; }
|
||||
.sec-title { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.sec-note { font-size: var(--text-sm); color: var(--text-tertiary); margin: 0; max-width: 60ch; }
|
||||
.pw-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; width: 100%; }
|
||||
.btn-row { display: flex; gap: 8px; }
|
||||
.twofa-enroll, .twofa-confirm { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 420px; }
|
||||
.qr-wrap { display: flex; gap: 16px; align-items: center; }
|
||||
.qr-img { width: 148px; height: 148px; border-radius: var(--radius-md); background: #fff; padding: 6px; flex: none; }
|
||||
.qr-side { display: flex; flex-direction: column; gap: 8px; }
|
||||
.secret { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-primary);
|
||||
background: var(--surface-inset); padding: 6px 9px; border-radius: var(--radius-sm); word-break: break-all; }
|
||||
|
||||
/* API tab */
|
||||
.api-stack { display: flex; flex-direction: column; gap: 16px; }
|
||||
.inline-code, .field-mono code, code.inline-code { font-family: var(--font-mono); font-size: var(--text-xs);
|
||||
background: var(--surface-inset); padding: 1px 5px; border-radius: var(--radius-sm); color: var(--text-primary); }
|
||||
.key-create { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.key-reveal { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px;
|
||||
background: var(--surface-raised-2); border-radius: var(--radius-md); box-shadow: var(--ring-default); }
|
||||
.key-reveal__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.key-reveal__value { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--accent-text);
|
||||
word-break: break-all; }
|
||||
.key-empty { font-size: var(--text-sm); color: var(--text-muted); padding: 12px 0; }
|
||||
.key-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
.key-table th { text-align: left; font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary);
|
||||
padding: 6px 10px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.key-table td { padding: 9px 10px; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); vertical-align: middle; }
|
||||
.key-actions { text-align: right; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.lic-grid { grid-template-columns: 1fr 1fr; }
|
||||
.pw-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,7 +105,7 @@ function handleBackToLogin() {
|
||||
<!-- Branding -->
|
||||
<div class="auth-brand">
|
||||
<div class="auth-brand__mark">
|
||||
<Logo :size="40" :glow="true" tagline="Game Server Operations" />
|
||||
<Logo :size="40" :glow="true" tagline="by Corrosion" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function syncPorts() {
|
||||
}
|
||||
|
||||
const connectionTypes = [
|
||||
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Corrosion host agent' },
|
||||
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via re-Agent' },
|
||||
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
||||
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
||||
]
|
||||
@@ -183,7 +183,7 @@ async function completeSetup() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Corrosion host agent install -->
|
||||
<!-- Step 2: re-Agent install -->
|
||||
<div v-if="step === 2" class="setup-card">
|
||||
<div class="setup-card__head setup-card__head--center">
|
||||
<div class="setup-icon">
|
||||
@@ -191,12 +191,12 @@ async function completeSetup() {
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="setup-card__title">Install the Corrosion host agent</h1>
|
||||
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
||||
<h1 class="setup-card__title">Install re-Agent</h1>
|
||||
<p class="setup-card__sub">re-Agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
||||
</div>
|
||||
|
||||
<div class="setup-code">
|
||||
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
|
||||
<p class="setup-code__comment"># Download re-Agent (Linux)</p>
|
||||
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
|
||||
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
||||
@@ -206,7 +206,7 @@ async function completeSetup() {
|
||||
</div>
|
||||
|
||||
<p class="setup-hint">
|
||||
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
|
||||
On Windows, download re-Agent from the Server page after setup. re-Agent connects outbound and auto-registers with Catalyst.
|
||||
</p>
|
||||
|
||||
<div class="setup-actions">
|
||||
|
||||
@@ -105,7 +105,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<h2 class="title">Real access to a real platform.</h2>
|
||||
<p class="lead">
|
||||
Early access is not a waitlist gimmick. It is how we manage onboarding while the
|
||||
platform stabilizes. You get the full Corrosion control plane — one tier at a time
|
||||
platform stabilizes. You get the full Catalyst Console control plane — one tier at a time
|
||||
as capacity opens.
|
||||
</p>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||
<b>Full control plane</b>
|
||||
<p>Agent, panel, wipes, console, plugins, schedules — all of it. Not a trimmed preview.</p>
|
||||
<p>re-Agent, Catalyst Console, wipes, plugins, schedules — all of it. Not a trimmed preview.</p>
|
||||
</div>
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||
@@ -248,17 +248,17 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div class="wrap">
|
||||
<div class="sec__head reveal">
|
||||
<span class="eyebrow">How it works</span>
|
||||
<h2 class="title">Install the agent. Never SSH again.</h2>
|
||||
<h2 class="title">Install re-Agent. Never SSH again.</h2>
|
||||
</div>
|
||||
<div class="steps reveal">
|
||||
<div class="step">
|
||||
<div class="step__n">1</div>
|
||||
<b>Install the host agent</b>
|
||||
<p>Download the Go binary from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
|
||||
<b>Install re-Agent</b>
|
||||
<p>Download re-Agent from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step__n">2</div>
|
||||
<b>Agent connects to Corrosion</b>
|
||||
<b>re-Agent connects to Corrosion</b>
|
||||
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||
// Dr. Flask, Ph.D. — the chemistry glossary's mascot. Cover card (v1 render),
|
||||
// plus the v2 intro video + a poster frame grabbed from it for the lightbox.
|
||||
import drFlask from '@/assets/mascots/drflask.png'
|
||||
import drFlaskVideo from '@/assets/mascots/drflask-intro.mp4'
|
||||
import drFlaskPoster from '@/assets/mascots/drflask-poster.jpg'
|
||||
|
||||
interface FaqItem {
|
||||
question: string
|
||||
@@ -28,12 +33,12 @@ const groups: FaqGroup[] = [
|
||||
{
|
||||
question: 'What if Corrosion itself is broken?',
|
||||
answer:
|
||||
'Platform bugs and agent issues go through structured bug reports in the panel. Operator and Network customers receive priority bug triage for platform-level issues. Direct server administration is not included in any support tier.',
|
||||
'Platform bugs and agent issues go through structured bug reports in Catalyst Console. Operator and Network customers receive priority bug triage for platform-level issues. Direct server administration is not included in any support tier.',
|
||||
},
|
||||
{
|
||||
question: 'Do you manage my server for me?',
|
||||
answer:
|
||||
'No. Corrosion provides the panel, the agent, automation workflows, diagnostics, and the control plane. You remain responsible for your host machine, operating system, network, firewall, game files, mods, and community rules.',
|
||||
'No. Corrosion provides Catalyst Console, re-Agent, automation workflows, diagnostics, and the control plane. You remain responsible for your host machine, operating system, network, firewall, game files, mods, and community rules.',
|
||||
},
|
||||
{
|
||||
question: 'Is hands-on help available?',
|
||||
@@ -54,7 +59,7 @@ const groups: FaqGroup[] = [
|
||||
{
|
||||
question: 'Do I need my own server?',
|
||||
answer:
|
||||
'Yes. Corrosion is bring-your-own-server. You supply the host machine — a VPS, dedicated server, or bare metal box running Windows or Linux. Corrosion provides the control plane, the agent, and the panel.',
|
||||
'Yes. Corrosion is bring-your-own-server. You supply the host machine — a VPS, dedicated server, or bare metal box running Windows or Linux. Corrosion provides the control plane, re-Agent, and Catalyst Console.',
|
||||
},
|
||||
{
|
||||
question: 'Does Corrosion host my game server for me?',
|
||||
@@ -64,7 +69,7 @@ const groups: FaqGroup[] = [
|
||||
{
|
||||
question: 'Do I need to open inbound firewall ports for Corrosion?',
|
||||
answer:
|
||||
'No. The host agent establishes a single outbound NATS connection to Corrosion\'s cloud. No inbound management ports are required. Your game server\'s player ports (RCON, game ports) remain as they have always been.',
|
||||
'No. re-Agent establishes a single outbound NATS connection to Corrosion\'s cloud. No inbound management ports are required. Your game server\'s player ports (RCON, game ports) remain as they have always been.',
|
||||
},
|
||||
{
|
||||
question: 'Does Corrosion replace AMP or Pterodactyl?',
|
||||
@@ -74,7 +79,7 @@ const groups: FaqGroup[] = [
|
||||
{
|
||||
question: 'What happens if Corrosion goes offline?',
|
||||
answer:
|
||||
'Your game servers continue running normally. Corrosion does not proxy gameplay traffic — it only handles management operations. If the panel or cloud is unreachable, your players are unaffected.',
|
||||
'Your game servers continue running normally. Corrosion does not proxy gameplay traffic — it only handles management operations. If Catalyst Console or the cloud is unreachable, your players are unaffected.',
|
||||
},
|
||||
{
|
||||
question: 'Can multiple admins manage the same server?',
|
||||
@@ -82,9 +87,9 @@ const groups: FaqGroup[] = [
|
||||
'Yes. Role-based access control (RBAC) is built in. You can grant team members specific permissions — from full admin access down to read-only viewer — without sharing credentials.',
|
||||
},
|
||||
{
|
||||
question: 'What OS does the agent run on?',
|
||||
question: 'What OS does re-Agent run on?',
|
||||
answer:
|
||||
'Both Windows and Linux are supported for the host agent. The agent binary is downloaded directly from your dashboard — there is no manual build or dependency setup required.',
|
||||
'Both Windows and Linux are supported for re-Agent. The re-Agent binary is downloaded directly from your dashboard — there is no manual build or dependency setup required.',
|
||||
},
|
||||
{
|
||||
question: 'Is my data isolated from other customers?',
|
||||
@@ -100,7 +105,7 @@ const groups: FaqGroup[] = [
|
||||
{
|
||||
question: 'Which games are supported?',
|
||||
answer:
|
||||
'Currently in active development or shipped: Rust, Dune: Awakening, Conan Exiles, and Soulmask. Each game has a purpose-built blueprint — not a generic template — that models its specific operational reality (wipe types, mod systems, cluster configurations). More games are planned.',
|
||||
'Currently in active development or shipped: Rust, Dune: Awakening, Conan Exiles, and Soulmask. Each game has a purpose-built Formula that models its specific operational reality (wipe types, mod systems, cluster configurations). More games are planned.',
|
||||
},
|
||||
{
|
||||
question: 'Does Corrosion support Rust plugin management?',
|
||||
@@ -110,7 +115,7 @@ const groups: FaqGroup[] = [
|
||||
{
|
||||
question: 'Can I run multiple game types on the same host machine?',
|
||||
answer:
|
||||
'Yes. A single host agent can supervise multiple game server processes — across different games — on the same machine. Each game instance has its own lifecycle, configuration, and wipe schedule.',
|
||||
'Yes. A single re-Agent can supervise multiple game server processes — across different games — on the same machine. Each game instance has its own lifecycle, configuration, and wipe schedule.',
|
||||
},
|
||||
{
|
||||
question: 'Does Corrosion handle Rust wipes?',
|
||||
@@ -152,12 +157,150 @@ const groups: FaqGroup[] = [
|
||||
},
|
||||
]
|
||||
|
||||
interface ChemTerm {
|
||||
term: string
|
||||
chem: string
|
||||
corro: string
|
||||
kicker: string
|
||||
}
|
||||
|
||||
const chemistry: ChemTerm[] = [
|
||||
{
|
||||
term: 'Catalyst Console',
|
||||
chem: 'A catalyst helps a reaction happen faster or more efficiently without being consumed by it.',
|
||||
corro: 'The control panel where you manage your game servers, players, plugins, files, wipes, schedules, and automation.',
|
||||
kicker: 'Mission control for your server community.',
|
||||
},
|
||||
{
|
||||
term: 're-Agent',
|
||||
chem: 'A reagent is something that participates in a chemical reaction.',
|
||||
corro: 'The lightweight agent installed on your server. It connects your hardware to Catalyst over secure outbound communication — so you never open inbound ports.',
|
||||
kicker: 'What lets Corrosion see, manage, and automate your host.',
|
||||
},
|
||||
{
|
||||
term: 'Substrate',
|
||||
chem: 'A substrate is the surface or material where a reaction happens.',
|
||||
corro: 'The bare-metal or virtual machine your game server runs on — the hardware surface underneath everything, where re-Agent lives and Formulae are applied.',
|
||||
kicker: 'Think "bedrock," but more chemistry.',
|
||||
},
|
||||
{
|
||||
term: 'Formulae',
|
||||
chem: 'A formula describes the ingredients, structure, or recipe for something.',
|
||||
corro: 'Reusable deployment recipes for games and server types — how a Rust server, Dune BattleGroup, Conan world, or Soulmask cluster should be configured and deployed.',
|
||||
kicker: 'Complex setups, made repeatable instead of manual.',
|
||||
},
|
||||
{
|
||||
term: 'Reactions',
|
||||
chem: 'A chemical reaction is a process where things change from one state to another.',
|
||||
corro: 'The jobs and workflows that change your server state — wipes, restarts, updates, backups, maintenance windows, deployments, and scheduled tasks.',
|
||||
kicker: 'When Corrosion does work, a Reaction is usually happening.',
|
||||
},
|
||||
{
|
||||
term: 'Compounds',
|
||||
chem: 'A compound is made when different elements combine into something that works as a whole.',
|
||||
corro: 'Grouped services or stack components that belong together — a game server, supporting services, shared storage, config, and helper processes as one unit.',
|
||||
kicker: 'Related pieces, treated as one operational unit.',
|
||||
},
|
||||
{
|
||||
term: 'Lab Notes',
|
||||
chem: 'Lab notes record what happened during an experiment.',
|
||||
corro: 'The logs, audit history, job results, and operational records — what happened, when it happened, and whether it succeeded.',
|
||||
kicker: 'If something goes sideways, start here.',
|
||||
},
|
||||
{
|
||||
term: 'The Exchange',
|
||||
chem: 'Ion exchange is a process where ions are swapped between materials.',
|
||||
corro: 'The native marketplace layer for server communities — item catalogs, VIP packages, payment processing, and automated in-game delivery.',
|
||||
kicker: 'Where your community trades value for perks, packages, and content.',
|
||||
},
|
||||
]
|
||||
|
||||
// The chemistry-true pipeline, rendered as a flow strip under the table.
|
||||
const flow = ['Formulae', 'Catalyst', 're-Agent', 'Substrate', 'Reaction', 'Lab Notes']
|
||||
|
||||
const openKey = ref<string | null>(null)
|
||||
|
||||
function toggle(key: string): void {
|
||||
openKey.value = openKey.value === key ? null : key
|
||||
}
|
||||
|
||||
// Dr. Flask intro video — plays in a phone-frame lightbox with custom controls.
|
||||
const videoOpen = ref<boolean>(false)
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const playing = ref(false)
|
||||
const isMuted = ref(false)
|
||||
const curTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const progressPct = computed(() =>
|
||||
duration.value > 0 ? (curTime.value / duration.value) * 100 : 0,
|
||||
)
|
||||
|
||||
function openVideo(): void {
|
||||
videoOpen.value = true
|
||||
document.body.style.overflow = 'hidden'
|
||||
// The cover click is a user gesture, so try to play with sound; if the
|
||||
// browser's autoplay policy blocks it, fall back to muted playback (always
|
||||
// allowed) and surface the unmute control.
|
||||
nextTick(() => {
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
v.currentTime = 0
|
||||
v.muted = false
|
||||
isMuted.value = false
|
||||
v.play().catch(() => {
|
||||
v.muted = true
|
||||
isMuted.value = true
|
||||
v.play().catch(() => {})
|
||||
})
|
||||
})
|
||||
}
|
||||
function closeVideo(): void {
|
||||
videoOpen.value = false
|
||||
document.body.style.overflow = ''
|
||||
videoEl.value?.pause()
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && videoOpen.value) closeVideo()
|
||||
}
|
||||
|
||||
function togglePlay(): void {
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
if (v.paused) v.play().catch(() => {})
|
||||
else v.pause()
|
||||
}
|
||||
function toggleMute(): void {
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
v.muted = !v.muted
|
||||
isMuted.value = v.muted
|
||||
}
|
||||
function seek(e: MouseEvent): void {
|
||||
const v = videoEl.value
|
||||
if (!v || !duration.value) return
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
|
||||
v.currentTime = pct * duration.value
|
||||
}
|
||||
function toggleFullscreen(): void {
|
||||
const v = videoEl.value as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
|
||||
if (!v) return
|
||||
const frame = v.closest('.phone') as (HTMLElement & { requestFullscreen?: () => Promise<void> }) | null
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen?.()
|
||||
} else if (frame?.requestFullscreen) {
|
||||
void frame.requestFullscreen()
|
||||
} else if (v.webkitEnterFullscreen) {
|
||||
v.webkitEnterFullscreen() // iOS Safari: only the <video> can go fullscreen
|
||||
}
|
||||
}
|
||||
function fmtTime(s: number): string {
|
||||
if (!Number.isFinite(s)) return '0:00'
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function itemKey(groupLabel: string, idx: number): string {
|
||||
return `${groupLabel}-${idx}`
|
||||
}
|
||||
@@ -181,8 +324,12 @@ function initReveal(): void {
|
||||
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||
}
|
||||
|
||||
onMounted(() => { initReveal() })
|
||||
onUnmounted(() => { io?.disconnect() })
|
||||
onMounted(() => { initReveal(); window.addEventListener('keydown', onKeydown) })
|
||||
onUnmounted(() => {
|
||||
io?.disconnect()
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -254,6 +401,72 @@ onUnmounted(() => { io?.disconnect() })
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CHEMISTRY GLOSSARY -->
|
||||
<section class="sec sec--lab" id="chemistry">
|
||||
<div class="wrap">
|
||||
<div class="lab-intro reveal">
|
||||
<button class="lab-intro__cover" type="button" @click="openVideo" aria-label="Play Dr. Flask intro video">
|
||||
<img :src="drFlask" alt="Dr. Flask, Ph.D. — Chemistry Teacher" class="lab-intro__card" />
|
||||
<span class="lab-intro__play"><Icon name="play" :size="24" /></span>
|
||||
<span class="lab-intro__watch">Watch the intro</span>
|
||||
</button>
|
||||
<div class="lab-intro__copy">
|
||||
<span class="eyebrow">Glossary</span>
|
||||
<h2 class="title">Brush up on your chemistry while managing your game server</h2>
|
||||
<p class="lead">
|
||||
Corrosion uses a chemistry-inspired naming system because running game servers is a lot
|
||||
like managing controlled reactions: the right ingredients, the right environment, the
|
||||
right timing, and a safe way to see what happened when the smoke clears.
|
||||
</p>
|
||||
<p class="lead">
|
||||
You don't need a chemistry degree to use Corrosion. The names are here to make the
|
||||
platform more memorable — and to give each part of the system a clear job.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The one-line summary for skimmers -->
|
||||
<p class="lab-plain reveal">
|
||||
<strong>In plain English:</strong> <strong>Catalyst</strong> is the console,
|
||||
<strong>re-Agent</strong> connects your server, <strong>Substrate</strong> is the hardware
|
||||
it runs on, <strong>Formulae</strong> define game deployments, and
|
||||
<strong>Lab Notes</strong> show you what happened.
|
||||
</p>
|
||||
|
||||
<div class="flow reveal" aria-hidden="true">
|
||||
<template v-for="(step, i) in flow" :key="step">
|
||||
<span class="flow__step">{{ step }}</span>
|
||||
<span v-if="i < flow.length - 1" class="flow__arr">→</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Full glossary, one card per term -->
|
||||
<div class="term-grid reveal">
|
||||
<div v-for="t in chemistry" :key="t.term" class="term-card">
|
||||
<h3 class="term-card__name">{{ t.term }}</h3>
|
||||
<p class="term-card__chem">{{ t.chem }}</p>
|
||||
<p class="term-card__corro">{{ t.corro }}</p>
|
||||
<p class="term-card__kick">{{ t.kicker }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dr. Flask sign-off -->
|
||||
<div class="drflask-card reveal">
|
||||
<h3 class="term-card__name">Dr. Flask</h3>
|
||||
<p class="term-card__corro">
|
||||
Dr. Flask is Corrosion's friendly chemistry guide. He turns up in the FAQ and help
|
||||
sections to explain Corrosion terms — without turning your server panel into a
|
||||
full-blown chemistry class.
|
||||
</p>
|
||||
<ul class="drflask-card__quips">
|
||||
<li>Helpful? <strong>Yes.</strong></li>
|
||||
<li>Mandatory? <strong>No.</strong></li>
|
||||
<li>Likely bubbling with questionable enthusiasm? <strong>Absolutely.</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SUPPORT CTA -->
|
||||
<section class="sec" id="support-cta" style="border-bottom:none">
|
||||
<div class="wrap">
|
||||
@@ -276,6 +489,48 @@ onUnmounted(() => { io?.disconnect() })
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DR. FLASK INTRO — phone-frame lightbox -->
|
||||
<Teleport to="body">
|
||||
<div v-if="videoOpen" class="vmodal" @click.self="closeVideo">
|
||||
<button class="vmodal__close" type="button" @click="closeVideo" aria-label="Close">
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
<div class="phone" role="dialog" aria-label="Dr. Flask intro video">
|
||||
<span class="phone__island" />
|
||||
<div class="phone__screen">
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="phone__media"
|
||||
:src="drFlaskVideo"
|
||||
:poster="drFlaskPoster"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@click="togglePlay"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@timeupdate="curTime = videoEl?.currentTime ?? 0"
|
||||
@loadedmetadata="duration = videoEl?.duration ?? 0"
|
||||
/>
|
||||
<div class="phone__controls">
|
||||
<button class="pc-btn" type="button" @click="togglePlay" :aria-label="playing ? 'Pause' : 'Play'">
|
||||
<Icon :name="playing ? 'pause' : 'play'" :size="18" />
|
||||
</button>
|
||||
<div class="pc-track" @click="seek">
|
||||
<span class="pc-fill" :style="{ width: progressPct + '%' }" />
|
||||
</div>
|
||||
<span class="pc-time">{{ fmtTime(curTime) }} / {{ fmtTime(duration) }}</span>
|
||||
<button class="pc-btn" type="button" @click="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
|
||||
<Icon :name="isMuted ? 'volume-x' : 'volume-2'" :size="17" />
|
||||
</button>
|
||||
<button class="pc-btn" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
|
||||
<Icon name="maximize" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -350,4 +605,260 @@ onUnmounted(() => { io?.disconnect() })
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Chemistry glossary — "lab zone": scope the accent tokens to green so the whole
|
||||
section (eyebrow, term color, flow chips, callout) reads as Dr. Flask's corner
|
||||
without touching the orange brand everywhere else. */
|
||||
.sec--lab {
|
||||
--accent: #3fb968;
|
||||
--accent-text: #5bd183;
|
||||
--accent-soft: rgba(82, 200, 124, 0.13);
|
||||
--accent-border: rgba(82, 200, 124, 0.34);
|
||||
}
|
||||
|
||||
.lab-intro {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 300px) 1fr;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
max-width: 920px;
|
||||
margin: 0 auto 40px;
|
||||
}
|
||||
.lab-intro__cover {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.lab-intro__card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default), 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.lab-intro__play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 8px 22px rgba(0, 0, 0, 0.45);
|
||||
transition: transform var(--dur-fast), background var(--dur-fast);
|
||||
}
|
||||
.lab-intro__play :deep(svg) { margin-left: 3px; } /* optical-center the play triangle */
|
||||
.lab-intro__watch {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 12px;
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.lab-intro__cover:hover .lab-intro__play {
|
||||
transform: scale(1.08);
|
||||
background: var(--accent);
|
||||
color: #06170d;
|
||||
}
|
||||
.lab-intro__copy { text-align: left; }
|
||||
.lab-intro__copy .eyebrow { display: block; margin-bottom: 12px; }
|
||||
.lab-intro__copy .title { margin: 0 0 14px; }
|
||||
.lab-plain {
|
||||
max-width: 920px;
|
||||
margin: 0 auto 14px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.65;
|
||||
padding: 14px 18px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
text-align: center;
|
||||
}
|
||||
.lab-plain strong { color: var(--accent-text); }
|
||||
|
||||
.flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
max-width: 920px;
|
||||
margin: 0 auto 28px;
|
||||
}
|
||||
.flow__step {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--accent-text);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flow__arr { color: var(--text-muted); font-size: var(--text-sm); }
|
||||
/* Term cards — the full glossary, one card per term */
|
||||
.term-grid {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.term-card {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.term-card__name { font-size: var(--text-base, 1rem); font-weight: 700; color: var(--accent-text); margin: 0; }
|
||||
.term-card__chem { font-size: var(--text-sm); color: var(--text-tertiary); font-style: italic; line-height: 1.55; margin: 0; }
|
||||
.term-card__corro { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; margin: 0; }
|
||||
.term-card__kick { font-size: var(--text-sm); color: var(--accent-text); font-weight: 600; line-height: 1.5; margin: 4px 0 0; }
|
||||
|
||||
/* Dr. Flask sign-off */
|
||||
.drflask-card {
|
||||
max-width: 920px;
|
||||
margin: 14px auto 0;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
padding: 20px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.drflask-card__quips {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 2px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.drflask-card__quips strong { color: var(--accent-text); }
|
||||
|
||||
/* Dr. Flask intro — phone-frame lightbox */
|
||||
.vmodal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(6, 8, 10, 0.8);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.vmodal__close {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 22px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast);
|
||||
}
|
||||
.vmodal__close:hover { background: rgba(255, 255, 255, 0.18); }
|
||||
|
||||
.phone {
|
||||
position: relative;
|
||||
height: min(78vh, 620px);
|
||||
aspect-ratio: 9 / 19.5;
|
||||
padding: 9px;
|
||||
border-radius: 42px;
|
||||
background: linear-gradient(155deg, #1c1f24, #0c0d10);
|
||||
box-shadow: 0 0 0 2px #2b2e34, 0 32px 70px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.phone__island {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 88px;
|
||||
height: 24px;
|
||||
border-radius: 14px;
|
||||
background: #000;
|
||||
z-index: 2;
|
||||
}
|
||||
.phone__screen {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 33px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.phone__media { width: 100%; height: 100%; object-fit: cover; background: #06070a; cursor: pointer; }
|
||||
.phone__controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 14px 14px 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.78), transparent);
|
||||
}
|
||||
.pc-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #fff;
|
||||
opacity: 0.95;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--dur-fast), color var(--dur-fast);
|
||||
}
|
||||
.pc-btn:hover { opacity: 1; color: #5bd183; }
|
||||
.pc-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pc-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; background: #5bd183; }
|
||||
.pc-time { font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.85; flex: none; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.lab-intro { grid-template-columns: 1fr; gap: 18px; justify-items: center; text-align: center; }
|
||||
.lab-intro__cover { max-width: 280px; }
|
||||
.lab-intro__card { max-width: 280px; }
|
||||
.lab-intro__copy { text-align: center; }
|
||||
.term-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,12 +41,12 @@ onUnmounted(() => { io?.disconnect() })
|
||||
</div>
|
||||
<span class="eyebrow">How it works</span>
|
||||
<h1 style="font-size:var(--text-5xl)">
|
||||
One agent.
|
||||
One re-Agent.
|
||||
<span class="accent">Every game. No SSH.</span>
|
||||
</h1>
|
||||
<p class="hero__sub">
|
||||
Install the host agent once on your Windows or Linux machine. Corrosion connects
|
||||
securely, outbound-only. You manage every game instance from the browser.
|
||||
Install re-Agent once on your Windows or Linux machine. Corrosion connects
|
||||
securely, outbound-only. You manage every game instance from Catalyst Console.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -55,20 +55,20 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<section class="sec" id="model">
|
||||
<div class="wrap">
|
||||
<div class="sec__head reveal">
|
||||
<span class="eyebrow">The agent model</span>
|
||||
<span class="eyebrow">The re-Agent model</span>
|
||||
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
|
||||
<p class="lead">
|
||||
Corrosion is not a hosting provider. You supply the hardware or the VPS. The host
|
||||
agent runs on that machine and bridges your game instances to Corrosion's control
|
||||
Corrosion is not a hosting provider. You supply the hardware or the VPS. re-Agent
|
||||
runs on that machine and bridges your game instances to Corrosion's control
|
||||
plane — securely, without opening inbound firewall ports.
|
||||
</p>
|
||||
</div>
|
||||
<div class="steps reveal">
|
||||
<div class="step">
|
||||
<div class="step__n">1</div>
|
||||
<b>Install the host agent</b>
|
||||
<b>Install re-Agent</b>
|
||||
<p>
|
||||
Download the Corrosion agent binary from your dashboard. Run it on any Windows
|
||||
Download the re-Agent binary from your dashboard. Run it on any Windows
|
||||
or Linux host. One agent per machine — it manages every game instance you assign
|
||||
to it.
|
||||
</p>
|
||||
@@ -77,7 +77,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div class="step__n">2</div>
|
||||
<b>It connects to Corrosion</b>
|
||||
<p>
|
||||
The agent makes a single outbound NATS connection to Corrosion's cloud. No
|
||||
re-Agent makes a single outbound NATS connection to Corrosion's cloud. No
|
||||
inbound ports. No open panels. No SSH required after initial setup.
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<b>Deploy and manage from the browser</b>
|
||||
<p>
|
||||
Create game instances, run wipes, manage plugins, schedule maintenance, and
|
||||
monitor players — all from the Corrosion panel at panel.corrosionmgmt.com.
|
||||
monitor players — all from Catalyst Console at panel.corrosionmgmt.com.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,8 +106,8 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<span class="eyebrow">Multi-game host runtime</span>
|
||||
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
|
||||
<p class="lead">
|
||||
The host agent is not a per-game process. It is a general-purpose ops runtime. One
|
||||
agent on a single machine can supervise multiple game server processes across
|
||||
re-Agent is not a per-game process. It is a general-purpose ops runtime. One
|
||||
re-Agent on a single machine can supervise multiple game server processes across
|
||||
different games — each with its own configuration, lifecycle, and wipe schedule.
|
||||
</p>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<span class="eyebrow">Connectivity model</span>
|
||||
<h2 class="title">Outbound-only. No exposed panel.</h2>
|
||||
<p class="lead">
|
||||
The host agent establishes one secure NATS connection to Corrosion's cloud. All
|
||||
re-Agent establishes one secure NATS connection to Corrosion's cloud. All
|
||||
commands flow through that channel. Your machine never needs to accept inbound
|
||||
connections from the internet.
|
||||
</p>
|
||||
@@ -234,8 +234,8 @@ onUnmounted(() => { io?.disconnect() })
|
||||
</div>
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||
<b>Corrosion agent</b>
|
||||
<p>A single Go binary. Runs as a service. Manages game processes, files, and updates.</p>
|
||||
<b>re-Agent</b>
|
||||
<p>A single binary. Runs as a service. Manages game processes, files, and updates.</p>
|
||||
</div>
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||
@@ -250,12 +250,12 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
|
||||
<b>Your browser</b>
|
||||
<p>The panel at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
|
||||
<p>Catalyst Console at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="techrow reveal">
|
||||
<span>Go host agent</span>
|
||||
<span>re-Agent</span>
|
||||
<span>NATS JetStream</span>
|
||||
<span>NestJS API</span>
|
||||
<span>PostgreSQL</span>
|
||||
@@ -289,7 +289,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div>
|
||||
<b>Enough CPU and RAM for your game</b>
|
||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||
Corrosion's agent is lightweight. Your game server determines the actual
|
||||
re-Agent is lightweight. Your game server determines the actual
|
||||
hardware requirement.
|
||||
</p>
|
||||
</div>
|
||||
@@ -299,7 +299,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div>
|
||||
<b>Outbound internet access</b>
|
||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||
The agent connects out; your game server's player ports stay open as they
|
||||
re-Agent connects out; your game server's player ports stay open as they
|
||||
always have been.
|
||||
</p>
|
||||
</div>
|
||||
@@ -310,7 +310,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div class="feat">
|
||||
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
||||
<div>
|
||||
<b>Agent binary (Windows or Linux)</b>
|
||||
<b>re-Agent binary (Windows or Linux)</b>
|
||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||
Downloaded from your dashboard. No manual build. No dependency management.
|
||||
</p>
|
||||
@@ -321,14 +321,14 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<div>
|
||||
<b>Your license key</b>
|
||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||
Issued when you register. The agent uses it to authenticate to the cloud.
|
||||
Issued when you register. re-Agent uses it to authenticate to the cloud.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feat">
|
||||
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
|
||||
<div>
|
||||
<b>The panel</b>
|
||||
<b>Catalyst Console</b>
|
||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||
Everything else — console, wipes, schedules, players — lives at
|
||||
panel.corrosionmgmt.com.
|
||||
@@ -344,7 +344,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<section class="finalcta">
|
||||
<div class="finalcta__atmo" />
|
||||
<div class="wrap finalcta__in reveal">
|
||||
<h2>Install the agent.<br>Never SSH again.</h2>
|
||||
<h2>Install re-Agent.<br>Never SSH again.</h2>
|
||||
<div class="cta-row">
|
||||
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||
Join early access
|
||||
|
||||
@@ -104,9 +104,9 @@ const mockActiveGame = activeGame
|
||||
</div>
|
||||
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
|
||||
<p class="hero__sub">
|
||||
Corrosion is a management panel for self-hosted survival game servers. Deploy servers, automate
|
||||
Corrosion is a management platform for self-hosted survival game servers. Deploy servers, automate
|
||||
wipes, manage plugins and mods, schedule maintenance, monitor players, and orchestrate
|
||||
multi-server worlds — from one command center.
|
||||
multi-server worlds — all from Catalyst Console.
|
||||
</p>
|
||||
<div class="hero__cta">
|
||||
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||
@@ -144,7 +144,7 @@ const mockActiveGame = activeGame
|
||||
<aside class="mock__side">
|
||||
<div class="mock__brand">
|
||||
<span class="mark"><CorrosionMark :size="18" /></span>
|
||||
<b>Corrosion</b>
|
||||
<b>Catalyst</b>
|
||||
</div>
|
||||
<div class="mock__gs">
|
||||
<span :class="{ on: mockActiveGame === 'rust' }">
|
||||
@@ -177,7 +177,7 @@ const mockActiveGame = activeGame
|
||||
<div class="v">234</div>
|
||||
</div>
|
||||
<div class="mock__kpi">
|
||||
<div class="l">Agent nodes</div>
|
||||
<div class="l">re-Agent nodes</div>
|
||||
<div class="v">2<small>/2</small></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@ const mockActiveGame = activeGame
|
||||
</div>
|
||||
<div class="wrap" style="text-align:center">
|
||||
<div class="hero__foot">
|
||||
One host agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
|
||||
One re-Agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
|
||||
Windows & Linux hosts
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ const mockActiveGame = activeGame
|
||||
</div>
|
||||
<p class="closing reveal">
|
||||
Your community sees the server. You deal with the chaos.<br>
|
||||
<span class="accent">Corrosion gives you the control plane.</span>
|
||||
<span class="accent">Catalyst Console gives you the control plane.</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -279,16 +279,16 @@ const mockActiveGame = activeGame
|
||||
<div class="wrap">
|
||||
<div class="sec__head reveal">
|
||||
<span class="eyebrow">The shift</span>
|
||||
<h2 class="title">Drop in the agent.<br>Take control from the panel.</h2>
|
||||
<h2 class="title">Drop in re-Agent.<br>Take control from Catalyst Console.</h2>
|
||||
<p class="lead">
|
||||
One lightweight host agent runs on your machine and manages every game instance you assign
|
||||
One lightweight re-Agent runs on your machine and manages every game instance you assign
|
||||
to it — an outbound-only ops runtime, not an exposed panel.
|
||||
</p>
|
||||
</div>
|
||||
<div class="steps reveal">
|
||||
<div class="step">
|
||||
<div class="step__n">1</div>
|
||||
<b>Install the Corrosion Agent</b>
|
||||
<b>Install re-Agent</b>
|
||||
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
@@ -310,7 +310,7 @@ const mockActiveGame = activeGame
|
||||
</div>
|
||||
<p class="closing reveal" style="font-size:var(--text-lg)">
|
||||
You provide the machine.
|
||||
<span class="accent">Corrosion provides the control plane.</span>
|
||||
<span class="accent">Catalyst Console provides the control plane.</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -320,10 +320,9 @@ const mockActiveGame = activeGame
|
||||
<div class="wrap">
|
||||
<div class="sec__head reveal">
|
||||
<span class="eyebrow">Supported games</span>
|
||||
<h2 class="title">Game-aware blueprints,<br>not generic templates</h2>
|
||||
<h2 class="title">Game-aware Formulae,<br>not generic configs</h2>
|
||||
<p class="lead">
|
||||
Every game has a different operational reality. Corrosion models each one as an operations
|
||||
blueprint — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
||||
Every game has a different operational reality. Corrosion models each one as a Formula — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
||||
worlds.
|
||||
</p>
|
||||
</div>
|
||||
@@ -527,7 +526,7 @@ const mockActiveGame = activeGame
|
||||
<span class="eyebrow">Built like infrastructure</span>
|
||||
<h2 class="title">Not a skin over SSH</h2>
|
||||
<p class="lead">
|
||||
A hosted control plane plus a host agent — with tenant isolation, command namespacing,
|
||||
A hosted control plane plus re-Agent — with tenant isolation, command namespacing,
|
||||
health reporting, and outbound-only connectivity.
|
||||
</p>
|
||||
</div>
|
||||
@@ -535,7 +534,7 @@ const mockActiveGame = activeGame
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||
<b>Agent-based control</b>
|
||||
<p>Your host connects to Corrosion. No exposed management panel required.</p>
|
||||
<p>re-Agent connects to Corrosion. No exposed management panel required.</p>
|
||||
</div>
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||
@@ -550,7 +549,7 @@ const mockActiveGame = activeGame
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||
<b>Event-driven</b>
|
||||
<p>NATS-powered messaging keeps agents and panel in sync.</p>
|
||||
<p>NATS-powered messaging keeps re-Agent and Catalyst Console in sync.</p>
|
||||
</div>
|
||||
<div class="icard">
|
||||
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
||||
@@ -562,7 +561,7 @@ const mockActiveGame = activeGame
|
||||
<span>NestJS</span>
|
||||
<span>NATS JetStream</span>
|
||||
<span>PostgreSQL</span>
|
||||
<span>Go host agent</span>
|
||||
<span>re-Agent</span>
|
||||
<span>Outbound-only</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -346,7 +346,7 @@ const plans: Plan[] = [
|
||||
|
||||
<p class="closing reveal" style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500">
|
||||
Direct server administration, firewall configuration, mod installation, and wipe-day
|
||||
hand-holding are not included in any plan. Corrosion gives you the panel and the tools.
|
||||
hand-holding are not included in any plan. Corrosion gives you Catalyst Console and the tools.
|
||||
You run the operation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -25,14 +25,14 @@ const groups: RoadmapGroup[] = [
|
||||
status: 'shipped',
|
||||
label: 'Phase 1 — Foundation',
|
||||
description:
|
||||
'The core control plane is live. Game 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 re-Agent, connect their server, and manage it entirely from Catalyst Console.',
|
||||
items: [
|
||||
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
|
||||
{ text: 're-Agent (Windows + Linux)', note: 'Outbound NATS, zero inbound ports' },
|
||||
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
|
||||
{ text: 'Auto-wiper with rollback', note: 'Map, BP, and full wipes as verified sequences' },
|
||||
{ text: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
|
||||
{ text: 'Real-time console', note: 'NATS-bridged live output' },
|
||||
{ text: 'File manager', note: 'Browser-based file access via the agent' },
|
||||
{ text: 'File manager', note: 'Browser-based file access via re-Agent' },
|
||||
{ text: 'Scheduled tasks and maintenance windows' },
|
||||
{ text: 'Player management and RBAC team access' },
|
||||
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
|
||||
@@ -40,39 +40,47 @@ const groups: RoadmapGroup[] = [
|
||||
{ text: 'Discord and notification webhooks' },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'shipped',
|
||||
label: 'Phase 2 — Multi-game runtime',
|
||||
description:
|
||||
're-Agent multi-instance support and the per-game scheduling engine are live. One agent process now manages N game server instances on the same host, and the auto-wiper and event scheduler fire per-game on their own cadence.',
|
||||
items: [
|
||||
{ text: 'Multi-instance host runtime', note: 'One re-Agent managing N game processes on the same machine' },
|
||||
{ text: 'Per-game wipe and event scheduling', note: 'Auto-wiper and event scheduler both fire per-game instance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'in-progress',
|
||||
label: 'Multi-game expansion',
|
||||
label: 'Multi-game Formulae',
|
||||
description:
|
||||
'The agent and control plane are being extended with per-game blueprints. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same agent model with game-specific operational logic.',
|
||||
'Per-game Formulae extend the control plane with game-specific operational logic. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model.',
|
||||
items: [
|
||||
{ text: 'Dune: Awakening blueprint', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' },
|
||||
{ text: 'Conan Exiles blueprint', note: 'Persistent world management, mod support, purge tracking' },
|
||||
{ text: 'Soulmask blueprint', note: 'Linked-world cluster deployment, port automation' },
|
||||
{ text: 'Multi-instance host runtime', note: 'One agent managing N game processes on the same machine' },
|
||||
{ text: 'Per-game wipe and event scheduling' },
|
||||
{ text: 'Dune: Awakening Formula', note: 'Battlegroup lifecycle shipped; Deep Desert wipe scheduling in progress' },
|
||||
{ text: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' },
|
||||
{ text: 'Soulmask Formula', note: 'Linked-world cluster deployment, port automation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'in-progress',
|
||||
label: 'Operator API and integrations',
|
||||
description:
|
||||
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane. Webhooks and per-license API keys are live; key-authenticated external API access lands next.',
|
||||
items: [
|
||||
{ text: 'Public REST API for server management', note: 'REST API live with OpenAPI docs; key-authenticated external access wired (corr_ bearer key acts as the license owner)' },
|
||||
{ text: 'Webhook events (wipe completed, server down, player banned)', note: 'Shipped — HMAC-SHA256 signed delivery, SSRF-guarded' },
|
||||
{ text: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'planned',
|
||||
label: 'API access and integrations',
|
||||
label: 'The Exchange',
|
||||
description:
|
||||
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane.',
|
||||
items: [
|
||||
{ text: 'Public REST API for server management' },
|
||||
{ text: 'Webhook events (wipe completed, server down, player banned)' },
|
||||
{ text: 'API key management per license' },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'planned',
|
||||
label: 'Integrated storefront',
|
||||
description:
|
||||
'A native store layer for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
|
||||
'Corrosion\'s native storefront for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
|
||||
items: [
|
||||
{ text: 'Item catalog and categories' },
|
||||
{ text: 'PayPal and Stripe payment processing' },
|
||||
{ text: 'Automated in-game delivery via RCON/agent' },
|
||||
{ text: 'Automated in-game delivery via RCON/re-Agent' },
|
||||
{ text: 'Transaction history and revenue dashboard' },
|
||||
],
|
||||
},
|
||||
@@ -83,9 +91,9 @@ const groups: RoadmapGroup[] = [
|
||||
'Tools for hosting providers and multi-server operations running 50+ instances across multiple physical hosts.',
|
||||
items: [
|
||||
{ text: 'Fleet-level dashboards and health monitoring' },
|
||||
{ text: 'Multi-host agent orchestration' },
|
||||
{ text: 'Multi-host re-Agent orchestration' },
|
||||
{ text: 'Bulk wipe and update scheduling across a fleet' },
|
||||
{ text: 'Fleet Block capacity management' },
|
||||
{ text: 'Fleet Block capacity management', note: 'Pooled host capacity, allocation, and utilization' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -95,7 +103,7 @@ const groups: RoadmapGroup[] = [
|
||||
'Corrosion\'s agent model is not Rust-specific. The platform is being designed to support any game that can be managed via process control, file operations, and RCON-style commands.',
|
||||
items: [
|
||||
{ text: 'Additional survival and sandbox games' },
|
||||
{ text: 'Community-requested game blueprints' },
|
||||
{ text: 'Community-requested game Formulae' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user