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 { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
import { FleetModule } from './modules/fleet/fleet.module';
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
import { InstancesModule } from './modules/instances/instances.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
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -137,6 +139,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
EarlyAccessModule,
|
EarlyAccessModule,
|
||||||
FleetModule,
|
FleetModule,
|
||||||
InstancesModule,
|
InstancesModule,
|
||||||
|
ApiKeysModule,
|
||||||
|
WebhooksModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// 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 { AuthGuard } from '@nestjs/passport';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private reflector: Reflector) {
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private readonly apiKeysService: ApiKeysService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (isPublic) return true;
|
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
|
// Super admins bypass all permission checks
|
||||||
if (user.is_super_admin) return true;
|
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
|
// Check permissions JSONB from role
|
||||||
const permissions = user.permissions as Record<string, boolean> | undefined;
|
const permissions = user.permissions as Record<string, boolean> | undefined;
|
||||||
if (!permissions) return false;
|
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.
|
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
|
||||||
const parts = requiredPermission.split('.');
|
const parts = requiredPermission.split('.');
|
||||||
const wildcard = parts[0] + '.*';
|
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 { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
import { VerifyTotpDto } from './dto/verify-totp.dto';
|
import { VerifyTotpDto } from './dto/verify-totp.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
@@ -61,6 +62,30 @@ export class AuthController {
|
|||||||
return this.authService.verifyTotp(userId, dto.code);
|
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')
|
@Get('me')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get current user profile' })
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
|||||||
@@ -335,6 +335,56 @@ export class AuthService {
|
|||||||
throw new NotImplementedException('Password reset not yet configured');
|
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
|
// Helper methods
|
||||||
|
|
||||||
private async generateTokens(user: User, licenseId?: string) {
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PlayerAction } from '../../entities/player-action.entity';
|
import { PlayerAction } from '../../entities/player-action.entity';
|
||||||
import { PlayerSession } from '../../entities/player-session.entity';
|
import { PlayerSession } from '../../entities/player-session.entity';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { PlayerActionDto } from './dto/player-action.dto';
|
import { PlayerActionDto } from './dto/player-action.dto';
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
@@ -24,6 +25,7 @@ export class PlayersService {
|
|||||||
@InjectRepository(PlayerSession)
|
@InjectRepository(PlayerSession)
|
||||||
private readonly sessionRepo: Repository<PlayerSession>,
|
private readonly sessionRepo: Repository<PlayerSession>,
|
||||||
private readonly instancesService: InstancesService,
|
private readonly instancesService: InstancesService,
|
||||||
|
private readonly webhooksService: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,18 +140,52 @@ export class PlayersService {
|
|||||||
await this.instancesService.rconForLicense(licenseId, rconCmd);
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRconCommand(dto: PlayerActionDto): string {
|
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) {
|
switch (dto.action_type) {
|
||||||
case 'kick':
|
case 'kick':
|
||||||
return `kick ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
|
return `kick ${id}${dto.reason ? ' ' + safeReason : ''}`;
|
||||||
case 'ban':
|
case 'ban':
|
||||||
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
|
// 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':
|
case 'unban':
|
||||||
return `unban ${dto.steam_id}`;
|
return `unban ${id}`;
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,47 +11,7 @@ import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
|||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { nextCronDate } from '../../common/cron.util';
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||||
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
||||||
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
||||||
import { WipeHistory } from '../../entities/wipe-history.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 { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { nextCronDate } from '../../common/cron.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WipesService {
|
export class WipesService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(WipesService.name);
|
private readonly logger = new Logger(WipesService.name);
|
||||||
|
private wipeExecutorInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(WipeProfile)
|
@InjectRepository(WipeProfile)
|
||||||
@@ -22,8 +31,85 @@ export class WipesService {
|
|||||||
@InjectRepository(WipeHistory)
|
@InjectRepository(WipeHistory)
|
||||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||||
private readonly instancesService: InstancesService,
|
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[]> {
|
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
||||||
return this.wipeProfileRepo.find({
|
return this.wipeProfileRepo.find({
|
||||||
where: { license_id: licenseId },
|
where: { license_id: licenseId },
|
||||||
@@ -96,19 +182,56 @@ export class WipesService {
|
|||||||
async triggerWipe(
|
async triggerWipe(
|
||||||
licenseId: string,
|
licenseId: string,
|
||||||
dto: TriggerWipeDto,
|
dto: TriggerWipeDto,
|
||||||
|
triggerType: 'manual' | 'scheduled' = 'manual',
|
||||||
): Promise<{ wipe_history_id: string }> {
|
): Promise<{ wipe_history_id: string }> {
|
||||||
const history = this.wipeHistoryRepo.create({
|
const history = this.wipeHistoryRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
wipe_type: dto.wipe_type,
|
wipe_type: dto.wipe_type,
|
||||||
wipe_profile_id: dto.wipe_profile_id,
|
wipe_profile_id: dto.wipe_profile_id,
|
||||||
trigger_type: 'manual',
|
trigger_type: triggerType,
|
||||||
status: 'pending',
|
status: 'wiping',
|
||||||
|
started_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.wipeHistoryRepo.save(history);
|
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);
|
// Dispatch to the agent WITHOUT blocking the caller — a wipe is
|
||||||
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
|
// 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 };
|
return { wipe_history_id: saved.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ServerConnection } from '../entities/server-connection.entity';
|
|||||||
import { License } from '../entities/license.entity';
|
import { License } from '../entities/license.entity';
|
||||||
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
|
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
|
||||||
import { GameInstance } from '../entities/game-instance.entity';
|
import { GameInstance } from '../entities/game-instance.entity';
|
||||||
|
import { WebhooksService } from '../modules/webhooks/webhooks.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumes Corrosion wire protocol v2 host-agent subjects
|
* Consumes Corrosion wire protocol v2 host-agent subjects
|
||||||
@@ -64,6 +65,7 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
|||||||
private readonly hostRepository: Repository<AgentHost>,
|
private readonly hostRepository: Repository<AgentHost>,
|
||||||
@InjectRepository(GameInstance)
|
@InjectRepository(GameInstance)
|
||||||
private readonly instanceRepository: Repository<GameInstance>,
|
private readonly instanceRepository: Repository<GameInstance>,
|
||||||
|
private readonly webhooksService: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Bootstrap, not module-init: subscriptions registered before NatsService
|
// Bootstrap, not module-init: subscriptions registered before NatsService
|
||||||
@@ -197,22 +199,52 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
|||||||
{ license_id: licenseId },
|
{ license_id: licenseId },
|
||||||
{ connection_status: 'offline', updated_at: now },
|
{ 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(
|
await this.hostRepository.update(
|
||||||
{ license_id: licenseId },
|
{ license_id: licenseId },
|
||||||
{ status: 'offline', updated_at: now },
|
{ status: 'offline', updated_at: now },
|
||||||
);
|
);
|
||||||
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
|
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
|
* Heartbeats stopping must flip the panel to offline — an agent that
|
||||||
* crashes or loses network never sends the goodbye beacon. Sweeps both the
|
* crashes or loses network never sends the goodbye beacon. Sweeps both the
|
||||||
* legacy connection and fleet hosts.
|
* 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)
|
@Interval(60_000)
|
||||||
async sweepStaleConnections(): Promise<void> {
|
async sweepStaleConnections(): Promise<void> {
|
||||||
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
|
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
|
const conn = await this.connectionRepository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(ServerConnection)
|
.update(ServerConnection)
|
||||||
@@ -235,6 +267,20 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
|||||||
if (affected) {
|
if (affected) {
|
||||||
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
|
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]]
|
[[package]]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.10"
|
version = "2.0.0-alpha.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.10"
|
version = "2.0.0-alpha.11"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||||
license = "UNLICENSED"
|
license = "UNLICENSED"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod instancecmd;
|
|||||||
pub mod prober;
|
pub mod prober;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod rcon;
|
pub mod rcon;
|
||||||
|
pub mod service;
|
||||||
pub mod steamcmd;
|
pub mod steamcmd;
|
||||||
pub mod subjects;
|
pub mod subjects;
|
||||||
pub mod supervisor;
|
pub mod supervisor;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use corrosion_host_agent::{
|
use corrosion_host_agent::{
|
||||||
agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process,
|
agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process,
|
||||||
subjects, supervisor, telemetry, version,
|
service, subjects, supervisor, telemetry, version,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -37,6 +37,10 @@ enum Command {
|
|||||||
Check,
|
Check,
|
||||||
/// Print full version (semver, git hash, build timestamp) and exit.
|
/// Print full version (semver, git hash, build timestamp) and exit.
|
||||||
Version,
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -58,6 +62,8 @@ fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Some(Command::Install) => service::install(&config_path),
|
||||||
|
Some(Command::Uninstall) => service::uninstall(),
|
||||||
None => {
|
None => {
|
||||||
let settings = config::load(&config_path)?;
|
let settings = config::load(&config_path)?;
|
||||||
init_logging(&settings.log_level);
|
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" />
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<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 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: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." />
|
<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">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Logo — Corrosion brand lockup.
|
* Logo — Catalyst brand lockup.
|
||||||
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
|
* Composes the CorrosionMark SVG + Oxanium wordmark "Catalyst" + optional tagline.
|
||||||
*
|
*
|
||||||
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
||||||
* parent (or pass `markColor`) to theme it per active game.
|
* parent (or pass `markColor`) to theme it per active game.
|
||||||
*
|
*
|
||||||
* Props mirror Logo.jsx exactly:
|
* Props mirror Logo.jsx exactly:
|
||||||
* size — base px size; drives mark em-size + wordmark scaling
|
* size — base px size; drives mark em-size + wordmark scaling
|
||||||
* wordmark — show the "Corrosion" text (default true)
|
* wordmark — show the "Catalyst" text (default true)
|
||||||
* tagline — false | true (→ "Management Panel") | custom string
|
* tagline — false | true (→ "by Corrosion") | custom string
|
||||||
* glow — accent drop-shadow for marketing / login hero use
|
* glow — accent drop-shadow for marketing / login hero use
|
||||||
* markColor — force a fixed color on the mark (bypasses currentColor theming)
|
* 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'
|
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
|
||||||
)
|
)
|
||||||
const tagText = computed(() =>
|
const tagText = computed(() =>
|
||||||
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
|
typeof props.tagline === 'string' ? props.tagline : 'by Corrosion'
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ const tagText = computed(() =>
|
|||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}"
|
}"
|
||||||
>Corrosion</span>
|
>Catalyst</span>
|
||||||
<span
|
<span
|
||||||
v-if="tagline"
|
v-if="tagline"
|
||||||
:style="{
|
:style="{
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const agentLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
// One host → its hostname; multiple → fleet count.
|
// One host → its hostname; multiple → fleet count.
|
||||||
const agentName = computed(() =>
|
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(() => {
|
const agentMetaLine = computed(() => {
|
||||||
@@ -231,9 +231,9 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
|||||||
<div v-else class="agent agent--empty">
|
<div v-else class="agent agent--empty">
|
||||||
<div class="agent__row">
|
<div class="agent__row">
|
||||||
<StatusDot tone="offline" />
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<!-- User / logout row -->
|
<!-- User / logout row -->
|
||||||
<div class="side__user">
|
<div class="side__user">
|
||||||
@@ -272,7 +272,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
|||||||
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div class="top__crumbs">
|
<div class="top__crumbs">
|
||||||
<span class="crumb">Corrosion</span>
|
<span class="crumb">Catalyst</span>
|
||||||
<span class="crumb__sep">/</span>
|
<span class="crumb__sep">/</span>
|
||||||
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
||||||
</div>
|
</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
|
* 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
|
* 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).
|
* multiple → 'all' (neutral house skin).
|
||||||
*
|
*
|
||||||
* NO-OP when the operator has a manual pick stored (cc-active-game present): an
|
* 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'),
|
component: () => import('@/views/marketing/HowItWorksView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'How It Works — Corrosion',
|
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'),
|
component: () => import('@/views/marketing/RoadmapView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Roadmap — Corrosion',
|
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',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/auth/LoginView.vue'),
|
component: () => import('@/views/auth/LoginView.vue'),
|
||||||
meta: { guest: true, title: 'Sign in — Corrosion' },
|
meta: { guest: true, title: 'Sign in to Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: () => import('@/views/auth/RegisterView.vue'),
|
component: () => import('@/views/auth/RegisterView.vue'),
|
||||||
meta: { guest: true, title: 'Create account — Corrosion' },
|
meta: { guest: true, title: 'Create account — Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
name: 'forgot-password',
|
name: 'forgot-password',
|
||||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||||
meta: { guest: true, title: 'Reset password — Corrosion' },
|
meta: { guest: true, title: 'Reset password — Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/setup',
|
path: '/setup',
|
||||||
name: 'setup-wizard',
|
name: 'setup-wizard',
|
||||||
component: () => import('@/views/auth/SetupWizardView.vue'),
|
component: () => import('@/views/auth/SetupWizardView.vue'),
|
||||||
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
|
meta: { requiresAuth: true, title: 'Setup — Catalyst' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin dashboard routes (with sidebar layout)
|
// Admin dashboard routes (with sidebar layout)
|
||||||
@@ -125,260 +125,260 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('@/views/admin/DashboardView.vue'),
|
component: () => import('@/views/admin/DashboardView.vue'),
|
||||||
meta: { title: 'Dashboard — Corrosion' },
|
meta: { title: 'Dashboard · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'server',
|
path: 'server',
|
||||||
name: 'server',
|
name: 'server',
|
||||||
component: () => import('@/views/admin/ServerView.vue'),
|
component: () => import('@/views/admin/ServerView.vue'),
|
||||||
meta: { title: 'Server — Corrosion' },
|
meta: { title: 'Server · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'console',
|
path: 'console',
|
||||||
name: 'console',
|
name: 'console',
|
||||||
component: () => import('@/views/admin/ConsoleView.vue'),
|
component: () => import('@/views/admin/ConsoleView.vue'),
|
||||||
meta: { title: 'Console — Corrosion' },
|
meta: { title: 'Console · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'players',
|
path: 'players',
|
||||||
name: 'players',
|
name: 'players',
|
||||||
component: () => import('@/views/admin/PlayersView.vue'),
|
component: () => import('@/views/admin/PlayersView.vue'),
|
||||||
meta: { title: 'Players — Corrosion' },
|
meta: { title: 'Players · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'plugins',
|
path: 'plugins',
|
||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
component: () => import('@/views/admin/PluginsView.vue'),
|
component: () => import('@/views/admin/PluginsView.vue'),
|
||||||
meta: { title: 'Plugins — Corrosion' },
|
meta: { title: 'Plugins · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'files',
|
path: 'files',
|
||||||
name: 'files',
|
name: 'files',
|
||||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||||
meta: { title: 'Files — Corrosion' },
|
meta: { title: 'Files · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'plugin-configs',
|
path: 'plugin-configs',
|
||||||
name: 'plugin-configs',
|
name: 'plugin-configs',
|
||||||
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
||||||
meta: { title: 'Plugin Configs — Corrosion' },
|
meta: { title: 'Plugin Configs · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'loot-builder',
|
path: 'loot-builder',
|
||||||
name: 'loot-builder',
|
name: 'loot-builder',
|
||||||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||||
meta: { title: 'Loot Builder — Corrosion' },
|
meta: { title: 'Loot Builder · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'teleport-config',
|
path: 'teleport-config',
|
||||||
name: 'teleport-config',
|
name: 'teleport-config',
|
||||||
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||||||
meta: { title: 'Teleport Config — Corrosion' },
|
meta: { title: 'Teleport Config · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'gather-manager',
|
path: 'gather-manager',
|
||||||
name: 'gather-manager',
|
name: 'gather-manager',
|
||||||
component: () => import('@/views/admin/GatherManagerView.vue'),
|
component: () => import('@/views/admin/GatherManagerView.vue'),
|
||||||
meta: { title: 'Gather Manager — Corrosion' },
|
meta: { title: 'Gather Manager · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'autodoors',
|
path: 'autodoors',
|
||||||
name: 'autodoors',
|
name: 'autodoors',
|
||||||
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
||||||
meta: { title: 'Auto Doors — Corrosion' },
|
meta: { title: 'Auto Doors · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'kits',
|
path: 'kits',
|
||||||
name: 'kits-config',
|
name: 'kits-config',
|
||||||
component: () => import('@/views/admin/KitsView.vue'),
|
component: () => import('@/views/admin/KitsView.vue'),
|
||||||
meta: { title: 'Kits — Corrosion' },
|
meta: { title: 'Kits · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'furnace-splitter',
|
path: 'furnace-splitter',
|
||||||
name: 'furnace-splitter',
|
name: 'furnace-splitter',
|
||||||
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
||||||
meta: { title: 'Furnace Splitter — Corrosion' },
|
meta: { title: 'Furnace Splitter · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'better-chat',
|
path: 'better-chat',
|
||||||
name: 'better-chat',
|
name: 'better-chat',
|
||||||
component: () => import('@/views/admin/BetterChatView.vue'),
|
component: () => import('@/views/admin/BetterChatView.vue'),
|
||||||
meta: { title: 'Better Chat — Corrosion' },
|
meta: { title: 'Better Chat · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'timed-execute',
|
path: 'timed-execute',
|
||||||
name: 'timed-execute',
|
name: 'timed-execute',
|
||||||
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
||||||
meta: { title: 'Timed Execute — Corrosion' },
|
meta: { title: 'Timed Execute · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'raidable-bases',
|
path: 'raidable-bases',
|
||||||
name: 'raidable-bases',
|
name: 'raidable-bases',
|
||||||
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
||||||
meta: { title: 'Raidable Bases — Corrosion' },
|
meta: { title: 'Raidable Bases · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes',
|
path: 'wipes',
|
||||||
name: 'wipes',
|
name: 'wipes',
|
||||||
component: () => import('@/views/admin/WipesView.vue'),
|
component: () => import('@/views/admin/WipesView.vue'),
|
||||||
meta: { title: 'Wipes — Corrosion' },
|
meta: { title: 'Wipes · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/profiles',
|
path: 'wipes/profiles',
|
||||||
name: 'wipe-profiles',
|
name: 'wipe-profiles',
|
||||||
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
||||||
meta: { title: 'Wipe Profiles — Corrosion' },
|
meta: { title: 'Wipe Profiles · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/calendar',
|
path: 'wipes/calendar',
|
||||||
name: 'wipe-calendar',
|
name: 'wipe-calendar',
|
||||||
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
||||||
meta: { title: 'Wipe Calendar — Corrosion' },
|
meta: { title: 'Wipe Calendar · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/history',
|
path: 'wipes/history',
|
||||||
name: 'wipe-history',
|
name: 'wipe-history',
|
||||||
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
||||||
meta: { title: 'Wipe History — Corrosion' },
|
meta: { title: 'Wipe History · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/analytics',
|
path: 'wipes/analytics',
|
||||||
name: 'wipe-analytics',
|
name: 'wipe-analytics',
|
||||||
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
||||||
meta: { title: 'Wipe Analytics — Corrosion' },
|
meta: { title: 'Wipe Analytics · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'maps',
|
path: 'maps',
|
||||||
name: 'maps',
|
name: 'maps',
|
||||||
component: () => import('@/views/admin/MapsView.vue'),
|
component: () => import('@/views/admin/MapsView.vue'),
|
||||||
meta: { title: 'Maps — Corrosion' },
|
meta: { title: 'Maps · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'maps/analytics',
|
path: 'maps/analytics',
|
||||||
name: 'map-analytics',
|
name: 'map-analytics',
|
||||||
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
||||||
meta: { title: 'Map Analytics — Corrosion' },
|
meta: { title: 'Map Analytics · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'chat',
|
path: 'chat',
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
component: () => import('@/views/admin/ChatLogView.vue'),
|
component: () => import('@/views/admin/ChatLogView.vue'),
|
||||||
meta: { title: 'Chat Log — Corrosion' },
|
meta: { title: 'Chat Log · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'analytics',
|
path: 'analytics',
|
||||||
name: 'analytics',
|
name: 'analytics',
|
||||||
component: () => import('@/views/admin/AnalyticsView.vue'),
|
component: () => import('@/views/admin/AnalyticsView.vue'),
|
||||||
meta: { title: 'Analytics — Corrosion' },
|
meta: { title: 'Analytics · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'retention',
|
path: 'retention',
|
||||||
name: 'retention',
|
name: 'retention',
|
||||||
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
||||||
meta: { title: 'Player Retention — Corrosion' },
|
meta: { title: 'Player Retention · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
name: 'notifications',
|
name: 'notifications',
|
||||||
component: () => import('@/views/admin/NotificationsView.vue'),
|
component: () => import('@/views/admin/NotificationsView.vue'),
|
||||||
meta: { title: 'Notifications — Corrosion' },
|
meta: { title: 'Notifications · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'team',
|
path: 'team',
|
||||||
name: 'team',
|
name: 'team',
|
||||||
component: () => import('@/views/admin/TeamView.vue'),
|
component: () => import('@/views/admin/TeamView.vue'),
|
||||||
meta: { title: 'Team — Corrosion' },
|
meta: { title: 'Team · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/config',
|
path: 'store/config',
|
||||||
name: 'store-config',
|
name: 'store-config',
|
||||||
component: () => import('@/views/admin/StoreConfigView.vue'),
|
component: () => import('@/views/admin/StoreConfigView.vue'),
|
||||||
meta: { title: 'Store Config — Corrosion' },
|
meta: { title: 'Store Config · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/items',
|
path: 'store/items',
|
||||||
name: 'store-items',
|
name: 'store-items',
|
||||||
component: () => import('@/views/admin/StoreItemsView.vue'),
|
component: () => import('@/views/admin/StoreItemsView.vue'),
|
||||||
meta: { title: 'Store Items — Corrosion' },
|
meta: { title: 'Store Items · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/revenue',
|
path: 'store/revenue',
|
||||||
name: 'store-revenue',
|
name: 'store-revenue',
|
||||||
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
||||||
meta: { title: 'Store Revenue — Corrosion' },
|
meta: { title: 'Store Revenue · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'modules',
|
path: 'modules',
|
||||||
name: 'modules',
|
name: 'modules',
|
||||||
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
||||||
meta: { title: 'Modules — Corrosion' },
|
meta: { title: 'Modules · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('@/views/admin/SettingsView.vue'),
|
component: () => import('@/views/admin/SettingsView.vue'),
|
||||||
meta: { title: 'Settings — Corrosion' },
|
meta: { title: 'Settings · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'schedules',
|
path: 'schedules',
|
||||||
name: 'schedules',
|
name: 'schedules',
|
||||||
component: () => import('@/views/admin/SchedulesView.vue'),
|
component: () => import('@/views/admin/SchedulesView.vue'),
|
||||||
meta: { title: 'Schedules — Corrosion' },
|
meta: { title: 'Schedules · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'migration',
|
path: 'migration',
|
||||||
name: 'migration',
|
name: 'migration',
|
||||||
component: () => import('@/views/admin/MigrationView.vue'),
|
component: () => import('@/views/admin/MigrationView.vue'),
|
||||||
meta: { title: 'Migration — Corrosion' },
|
meta: { title: 'Migration · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
name: 'changelog',
|
name: 'changelog',
|
||||||
component: () => import('@/views/admin/ChangelogView.vue'),
|
component: () => import('@/views/admin/ChangelogView.vue'),
|
||||||
meta: { title: 'Changelog — Corrosion' },
|
meta: { title: 'Changelog · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'alerts',
|
path: 'alerts',
|
||||||
name: 'alerts',
|
name: 'alerts',
|
||||||
component: () => import('@/views/admin/AlertsView.vue'),
|
component: () => import('@/views/admin/AlertsView.vue'),
|
||||||
meta: { title: 'Alerts — Corrosion' },
|
meta: { title: 'Alerts · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'fleet',
|
path: 'fleet',
|
||||||
name: 'fleet',
|
name: 'fleet',
|
||||||
component: () => import('@/views/admin/FleetView.vue'),
|
component: () => import('@/views/admin/FleetView.vue'),
|
||||||
meta: { title: 'Fleet — Corrosion', requiresAuth: true },
|
meta: { title: 'Fleet · Catalyst', requiresAuth: true },
|
||||||
},
|
},
|
||||||
// Platform Admin views (super-admin only)
|
// Platform Admin views (super-admin only)
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
name: 'platform-admin',
|
name: 'platform-admin',
|
||||||
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/licenses',
|
path: 'admin/licenses',
|
||||||
name: 'platform-licenses',
|
name: 'platform-licenses',
|
||||||
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin: Licenses · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/subscriptions',
|
path: 'admin/subscriptions',
|
||||||
name: 'platform-subscriptions',
|
name: 'platform-subscriptions',
|
||||||
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin: Subscriptions · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
name: 'platform-users',
|
name: 'platform-users',
|
||||||
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin: Users · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/servers',
|
path: 'admin/servers',
|
||||||
name: 'platform-servers',
|
name: 'platform-servers',
|
||||||
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
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',
|
path: '/status',
|
||||||
name: 'status',
|
name: 'status',
|
||||||
component: () => import('@/views/public/StatusPageView.vue'),
|
component: () => import('@/views/public/StatusPageView.vue'),
|
||||||
meta: { title: 'Status — Corrosion' },
|
meta: { title: 'Status · Catalyst' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Catch-all
|
// Catch-all
|
||||||
@@ -488,14 +488,14 @@ function setOrClearMeta(selector: string, attr: string, value: string): void {
|
|||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
// Title
|
// Title
|
||||||
document.title = to.meta.title ?? 'Corrosion Management'
|
document.title = to.meta.title ?? 'Catalyst Console'
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
const desc = to.meta.description ?? ''
|
const desc = to.meta.description ?? ''
|
||||||
setOrClearMeta('meta[name="description"]', 'content', desc)
|
setOrClearMeta('meta[name="description"]', 'content', desc)
|
||||||
|
|
||||||
// OG title
|
// 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
|
// OG description
|
||||||
setOrClearMeta('meta[property="og:description"]', 'content', desc)
|
setOrClearMeta('meta[property="og:description"]', 'content', desc)
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
? decodeJwtPermissions(accessToken.value)
|
? 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 {
|
return {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
async function list(path: string): Promise<void> {
|
async function list(path: string): Promise<void> {
|
||||||
const id = currentId()
|
const id = currentId()
|
||||||
if (!id) {
|
if (!id) {
|
||||||
error.value = 'No instance — connect the host agent'
|
error.value = 'No instance — connect re-Agent'
|
||||||
entries.value = []
|
entries.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function handleWebSocketMessage(message: WebSocketMessage) {
|
|||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
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')
|
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||||
if (server.connection?.connection_status !== 'connected') {
|
if (server.connection?.connection_status !== 'connected') {
|
||||||
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ function navServer() { router.push('/server') }
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No server connected"
|
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>
|
<template #action>
|
||||||
<Button icon="server" @click="navServer">Set up server</Button>
|
<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">
|
<div class="dash__col dash__col--side">
|
||||||
|
|
||||||
<!-- Resources — real stats from agent; null = '—' -->
|
<!-- Resources — real stats from agent; null = '—' -->
|
||||||
<Panel title="Resources" subtitle="Host agent telemetry">
|
<Panel title="Resources" subtitle="re-Agent telemetry">
|
||||||
<div class="solo-meters">
|
<div class="solo-meters">
|
||||||
<ResourceMeter
|
<ResourceMeter
|
||||||
label="CPU"
|
label="CPU"
|
||||||
@@ -418,7 +418,7 @@ function navServer() { router.push('/server') }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
<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">
|
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
||||||
Agent setup
|
Agent setup
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ async function confirmDelete(path: string) {
|
|||||||
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
|
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No host agent connected"
|
title="No re-Agent connected"
|
||||||
description="Install the host agent from the Server page to manage files on your game server."
|
description="Install re-Agent from the Server page to manage files on your game server."
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
|
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function relativeHeartbeat(iso: string | null): string {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No hosts connected yet"
|
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>
|
<template #action>
|
||||||
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>
|
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const tabItems = [
|
|||||||
function sourceLabel(source: string): string {
|
function sourceLabel(source: string): string {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'umod': return 'uMod'
|
case 'umod': return 'uMod'
|
||||||
case 'corrosion_module': return 'Corrosion'
|
case 'corrosion_module': return 'Catalyst'
|
||||||
case 'manual': return 'Manual'
|
case 'manual': return 'Manual'
|
||||||
default: return source
|
default: return source
|
||||||
}
|
}
|
||||||
@@ -485,7 +485,7 @@ onMounted(() => {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Alert tone="info">
|
<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.
|
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ async function saveConfig() {
|
|||||||
|
|
||||||
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||||
if (!currentInstance.value) {
|
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
|
return
|
||||||
}
|
}
|
||||||
actionLoading.value = action
|
actionLoading.value = action
|
||||||
@@ -532,7 +532,7 @@ onMounted(async () => {
|
|||||||
v-if="!currentInstance"
|
v-if="!currentInstance"
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No game instance connected"
|
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>
|
<template v-else>
|
||||||
@@ -611,7 +611,7 @@ onMounted(async () => {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Host agent -->
|
<!-- Host agent -->
|
||||||
<Panel title="Host agent" subtitle="Bare-metal server management binary">
|
<Panel title="re-Agent" subtitle="Bare-metal server management binary">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
||||||
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
||||||
@@ -640,7 +640,7 @@ onMounted(async () => {
|
|||||||
<!-- Download -->
|
<!-- Download -->
|
||||||
<div class="sv__section-head">
|
<div class="sv__section-head">
|
||||||
<Icon name="download" :size="14" />
|
<Icon name="download" :size="14" />
|
||||||
<span>Download host agent</span>
|
<span>Download re-Agent</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sv__downloads sv__mb">
|
<div class="sv__downloads sv__mb">
|
||||||
<a
|
<a
|
||||||
@@ -683,7 +683,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Linux commands -->
|
<!-- Linux commands -->
|
||||||
<div v-if="setupTab === 'linux'" class="sv__codeblock">
|
<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>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>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>
|
<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 -->
|
<!-- Windows commands -->
|
||||||
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
||||||
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
<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>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 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>
|
<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>
|
<pre class="sv__pre">{{ agentTomlConfig }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<Alert v-if="!agentCreds" tone="warn" class="sv__mt">
|
<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>
|
</Alert>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -858,7 +858,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="box"
|
icon="box"
|
||||||
title="Docker-managed deployment"
|
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>
|
<template #action>
|
||||||
<Badge tone="info">Docker · Compose</Badge>
|
<Badge tone="info">Docker · Compose</Badge>
|
||||||
@@ -929,7 +929,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="layers"
|
icon="layers"
|
||||||
:title="profile.label + ' mod management'"
|
: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>
|
</Panel>
|
||||||
|
|
||||||
@@ -973,7 +973,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="network"
|
icon="network"
|
||||||
title="Cluster management coming soon"
|
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>
|
</Panel>
|
||||||
|
|
||||||
@@ -986,7 +986,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="map"
|
icon="map"
|
||||||
title="Sietch management requires a connected Dune host"
|
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>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,31 @@ const publicSiteForm = ref({
|
|||||||
status_page_description: '',
|
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() {
|
async function loadForms() {
|
||||||
if (auth.user) {
|
if (auth.user) {
|
||||||
accountForm.value.username = auth.user.username
|
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(() => {
|
onMounted(() => {
|
||||||
loadForms()
|
loadForms()
|
||||||
|
loadApiKeys()
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ value: 'account', label: 'Account', icon: 'user' },
|
{ 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: 'license', label: 'License', icon: 'key' },
|
||||||
{ value: 'domain', label: 'Domain', icon: 'globe' },
|
{ value: 'domain', label: 'Domain', icon: 'globe' },
|
||||||
{ value: 'public', label: 'Public status', icon: 'eye' },
|
{ value: 'public', label: 'Public status', icon: 'eye' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const swaggerUrl = '/api/docs'
|
||||||
|
function openApiDocs() {
|
||||||
|
window.open(swaggerUrl, '_blank', 'noopener')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -142,6 +295,120 @@ const tabItems = [
|
|||||||
>
|
>
|
||||||
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -309,8 +576,44 @@ const tabItems = [
|
|||||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
.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) {
|
@media (max-width: 680px) {
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
.lic-grid { grid-template-columns: 1fr 1fr; }
|
.lic-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.pw-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function handleBackToLogin() {
|
|||||||
<!-- Branding -->
|
<!-- Branding -->
|
||||||
<div class="auth-brand">
|
<div class="auth-brand">
|
||||||
<div class="auth-brand__mark">
|
<div class="auth-brand__mark">
|
||||||
<Logo :size="40" :glow="true" tagline="Game Server Operations" />
|
<Logo :size="40" :glow="true" tagline="by Corrosion" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function syncPorts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectionTypes = [
|
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: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
||||||
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
||||||
]
|
]
|
||||||
@@ -183,7 +183,7 @@ async function completeSetup() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Corrosion host agent install -->
|
<!-- Step 2: re-Agent install -->
|
||||||
<div v-if="step === 2" class="setup-card">
|
<div v-if="step === 2" class="setup-card">
|
||||||
<div class="setup-card__head setup-card__head--center">
|
<div class="setup-card__head setup-card__head--center">
|
||||||
<div class="setup-icon">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="setup-card__title">Install the Corrosion host agent</h1>
|
<h1 class="setup-card__title">Install re-Agent</h1>
|
||||||
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
<p class="setup-card__sub">re-Agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-code">
|
<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">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__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
||||||
@@ -206,7 +206,7 @@ async function completeSetup() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setup-hint">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="setup-actions">
|
<div class="setup-actions">
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<h2 class="title">Real access to a real platform.</h2>
|
<h2 class="title">Real access to a real platform.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Early access is not a waitlist gimmick. It is how we manage onboarding while the
|
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.
|
as capacity opens.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +114,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
<b>Full control plane</b>
|
<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>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
@@ -248,17 +248,17 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<div class="sec__head reveal">
|
||||||
<span class="eyebrow">How it works</span>
|
<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>
|
||||||
<div class="steps reveal">
|
<div class="steps reveal">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">1</div>
|
<div class="step__n">1</div>
|
||||||
<b>Install the host agent</b>
|
<b>Install re-Agent</b>
|
||||||
<p>Download the Go binary from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
|
<p>Download re-Agent from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">2</div>
|
<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>
|
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
import CorrosionMark from '@/components/brand/CorrosionMark.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 {
|
interface FaqItem {
|
||||||
question: string
|
question: string
|
||||||
@@ -28,12 +33,12 @@ const groups: FaqGroup[] = [
|
|||||||
{
|
{
|
||||||
question: 'What if Corrosion itself is broken?',
|
question: 'What if Corrosion itself is broken?',
|
||||||
answer:
|
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?',
|
question: 'Do you manage my server for me?',
|
||||||
answer:
|
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?',
|
question: 'Is hands-on help available?',
|
||||||
@@ -54,7 +59,7 @@ const groups: FaqGroup[] = [
|
|||||||
{
|
{
|
||||||
question: 'Do I need my own server?',
|
question: 'Do I need my own server?',
|
||||||
answer:
|
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?',
|
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?',
|
question: 'Do I need to open inbound firewall ports for Corrosion?',
|
||||||
answer:
|
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?',
|
question: 'Does Corrosion replace AMP or Pterodactyl?',
|
||||||
@@ -74,7 +79,7 @@ const groups: FaqGroup[] = [
|
|||||||
{
|
{
|
||||||
question: 'What happens if Corrosion goes offline?',
|
question: 'What happens if Corrosion goes offline?',
|
||||||
answer:
|
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?',
|
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.',
|
'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:
|
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?',
|
question: 'Is my data isolated from other customers?',
|
||||||
@@ -100,7 +105,7 @@ const groups: FaqGroup[] = [
|
|||||||
{
|
{
|
||||||
question: 'Which games are supported?',
|
question: 'Which games are supported?',
|
||||||
answer:
|
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?',
|
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?',
|
question: 'Can I run multiple game types on the same host machine?',
|
||||||
answer:
|
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?',
|
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)
|
const openKey = ref<string | null>(null)
|
||||||
|
|
||||||
function toggle(key: string): void {
|
function toggle(key: string): void {
|
||||||
openKey.value = openKey.value === key ? null : key
|
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 {
|
function itemKey(groupLabel: string, idx: number): string {
|
||||||
return `${groupLabel}-${idx}`
|
return `${groupLabel}-${idx}`
|
||||||
}
|
}
|
||||||
@@ -181,8 +324,12 @@ function initReveal(): void {
|
|||||||
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { initReveal() })
|
onMounted(() => { initReveal(); window.addEventListener('keydown', onKeydown) })
|
||||||
onUnmounted(() => { io?.disconnect() })
|
onUnmounted(() => {
|
||||||
|
io?.disconnect()
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -254,6 +401,72 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- SUPPORT CTA -->
|
||||||
<section class="sec" id="support-cta" style="border-bottom:none">
|
<section class="sec" id="support-cta" style="border-bottom:none">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -276,6 +489,48 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -350,4 +605,260 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
line-height: 1.65;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
</div>
|
</div>
|
||||||
<span class="eyebrow">How it works</span>
|
<span class="eyebrow">How it works</span>
|
||||||
<h1 style="font-size:var(--text-5xl)">
|
<h1 style="font-size:var(--text-5xl)">
|
||||||
One agent.
|
One re-Agent.
|
||||||
<span class="accent">Every game. No SSH.</span>
|
<span class="accent">Every game. No SSH.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero__sub">
|
<p class="hero__sub">
|
||||||
Install the host agent once on your Windows or Linux machine. Corrosion connects
|
Install re-Agent once on your Windows or Linux machine. Corrosion connects
|
||||||
securely, outbound-only. You manage every game instance from the browser.
|
securely, outbound-only. You manage every game instance from Catalyst Console.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -55,20 +55,20 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<section class="sec" id="model">
|
<section class="sec" id="model">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<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>
|
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Corrosion is not a hosting provider. You supply the hardware or the VPS. The host
|
Corrosion is not a hosting provider. You supply the hardware or the VPS. re-Agent
|
||||||
agent runs on that machine and bridges your game instances to Corrosion's control
|
runs on that machine and bridges your game instances to Corrosion's control
|
||||||
plane — securely, without opening inbound firewall ports.
|
plane — securely, without opening inbound firewall ports.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="steps reveal">
|
<div class="steps reveal">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">1</div>
|
<div class="step__n">1</div>
|
||||||
<b>Install the host agent</b>
|
<b>Install re-Agent</b>
|
||||||
<p>
|
<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
|
or Linux host. One agent per machine — it manages every game instance you assign
|
||||||
to it.
|
to it.
|
||||||
</p>
|
</p>
|
||||||
@@ -77,7 +77,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="step__n">2</div>
|
<div class="step__n">2</div>
|
||||||
<b>It connects to Corrosion</b>
|
<b>It connects to Corrosion</b>
|
||||||
<p>
|
<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.
|
inbound ports. No open panels. No SSH required after initial setup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +86,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<b>Deploy and manage from the browser</b>
|
<b>Deploy and manage from the browser</b>
|
||||||
<p>
|
<p>
|
||||||
Create game instances, run wipes, manage plugins, schedule maintenance, and
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,8 +106,8 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<span class="eyebrow">Multi-game host runtime</span>
|
<span class="eyebrow">Multi-game host runtime</span>
|
||||||
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
|
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
The host agent is not a per-game process. It is a general-purpose ops runtime. One
|
re-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 on a single machine can supervise multiple game server processes across
|
||||||
different games — each with its own configuration, lifecycle, and wipe schedule.
|
different games — each with its own configuration, lifecycle, and wipe schedule.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +220,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<span class="eyebrow">Connectivity model</span>
|
<span class="eyebrow">Connectivity model</span>
|
||||||
<h2 class="title">Outbound-only. No exposed panel.</h2>
|
<h2 class="title">Outbound-only. No exposed panel.</h2>
|
||||||
<p class="lead">
|
<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
|
commands flow through that channel. Your machine never needs to accept inbound
|
||||||
connections from the internet.
|
connections from the internet.
|
||||||
</p>
|
</p>
|
||||||
@@ -234,8 +234,8 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
</div>
|
</div>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
<b>Corrosion agent</b>
|
<b>re-Agent</b>
|
||||||
<p>A single Go binary. Runs as a service. Manages game processes, files, and updates.</p>
|
<p>A single binary. Runs as a service. Manages game processes, files, and updates.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
@@ -250,12 +250,12 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
|
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
|
||||||
<b>Your browser</b>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="techrow reveal">
|
<div class="techrow reveal">
|
||||||
<span>Go host agent</span>
|
<span>re-Agent</span>
|
||||||
<span>NATS JetStream</span>
|
<span>NATS JetStream</span>
|
||||||
<span>NestJS API</span>
|
<span>NestJS API</span>
|
||||||
<span>PostgreSQL</span>
|
<span>PostgreSQL</span>
|
||||||
@@ -289,7 +289,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div>
|
<div>
|
||||||
<b>Enough CPU and RAM for your game</b>
|
<b>Enough CPU and RAM for your game</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<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.
|
hardware requirement.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +299,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div>
|
<div>
|
||||||
<b>Outbound internet access</b>
|
<b>Outbound internet access</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<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.
|
always have been.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,7 +310,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="feat">
|
<div class="feat">
|
||||||
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
||||||
<div>
|
<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)">
|
<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.
|
Downloaded from your dashboard. No manual build. No dependency management.
|
||||||
</p>
|
</p>
|
||||||
@@ -321,14 +321,14 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div>
|
<div>
|
||||||
<b>Your license key</b>
|
<b>Your license key</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feat">
|
<div class="feat">
|
||||||
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
|
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
|
||||||
<div>
|
<div>
|
||||||
<b>The panel</b>
|
<b>Catalyst Console</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
Everything else — console, wipes, schedules, players — lives at
|
Everything else — console, wipes, schedules, players — lives at
|
||||||
panel.corrosionmgmt.com.
|
panel.corrosionmgmt.com.
|
||||||
@@ -344,7 +344,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<section class="finalcta">
|
<section class="finalcta">
|
||||||
<div class="finalcta__atmo" />
|
<div class="finalcta__atmo" />
|
||||||
<div class="wrap finalcta__in reveal">
|
<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">
|
<div class="cta-row">
|
||||||
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
Join early access
|
Join early access
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
|
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
|
||||||
<p class="hero__sub">
|
<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
|
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>
|
</p>
|
||||||
<div class="hero__cta">
|
<div class="hero__cta">
|
||||||
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
@@ -144,7 +144,7 @@ const mockActiveGame = activeGame
|
|||||||
<aside class="mock__side">
|
<aside class="mock__side">
|
||||||
<div class="mock__brand">
|
<div class="mock__brand">
|
||||||
<span class="mark"><CorrosionMark :size="18" /></span>
|
<span class="mark"><CorrosionMark :size="18" /></span>
|
||||||
<b>Corrosion</b>
|
<b>Catalyst</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="mock__gs">
|
<div class="mock__gs">
|
||||||
<span :class="{ on: mockActiveGame === 'rust' }">
|
<span :class="{ on: mockActiveGame === 'rust' }">
|
||||||
@@ -177,7 +177,7 @@ const mockActiveGame = activeGame
|
|||||||
<div class="v">234</div>
|
<div class="v">234</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mock__kpi">
|
<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 class="v">2<small>/2</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +219,7 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<div class="wrap" style="text-align:center">
|
<div class="wrap" style="text-align:center">
|
||||||
<div class="hero__foot">
|
<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
|
Windows & Linux hosts
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +269,7 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<p class="closing reveal">
|
<p class="closing reveal">
|
||||||
Your community sees the server. You deal with the chaos.<br>
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -279,16 +279,16 @@ const mockActiveGame = activeGame
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<div class="sec__head reveal">
|
||||||
<span class="eyebrow">The shift</span>
|
<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">
|
<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.
|
to it — an outbound-only ops runtime, not an exposed panel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="steps reveal">
|
<div class="steps reveal">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">1</div>
|
<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>
|
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
@@ -310,7 +310,7 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<p class="closing reveal" style="font-size:var(--text-lg)">
|
<p class="closing reveal" style="font-size:var(--text-lg)">
|
||||||
You provide the machine.
|
You provide the machine.
|
||||||
<span class="accent">Corrosion provides the control plane.</span>
|
<span class="accent">Catalyst Console provides the control plane.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -320,10 +320,9 @@ const mockActiveGame = activeGame
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<div class="sec__head reveal">
|
||||||
<span class="eyebrow">Supported games</span>
|
<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">
|
<p class="lead">
|
||||||
Every game has a different operational reality. Corrosion models each one as an operations
|
Every game has a different operational reality. Corrosion models each one as a Formula — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
||||||
blueprint — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
|
||||||
worlds.
|
worlds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -527,7 +526,7 @@ const mockActiveGame = activeGame
|
|||||||
<span class="eyebrow">Built like infrastructure</span>
|
<span class="eyebrow">Built like infrastructure</span>
|
||||||
<h2 class="title">Not a skin over SSH</h2>
|
<h2 class="title">Not a skin over SSH</h2>
|
||||||
<p class="lead">
|
<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.
|
health reporting, and outbound-only connectivity.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,7 +534,7 @@ const mockActiveGame = activeGame
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
<b>Agent-based control</b>
|
<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>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
@@ -550,7 +549,7 @@ const mockActiveGame = activeGame
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
<b>Event-driven</b>
|
<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>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
||||||
@@ -562,7 +561,7 @@ const mockActiveGame = activeGame
|
|||||||
<span>NestJS</span>
|
<span>NestJS</span>
|
||||||
<span>NATS JetStream</span>
|
<span>NATS JetStream</span>
|
||||||
<span>PostgreSQL</span>
|
<span>PostgreSQL</span>
|
||||||
<span>Go host agent</span>
|
<span>re-Agent</span>
|
||||||
<span>Outbound-only</span>
|
<span>Outbound-only</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
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.
|
You run the operation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ const groups: RoadmapGroup[] = [
|
|||||||
status: 'shipped',
|
status: 'shipped',
|
||||||
label: 'Phase 1 — Foundation',
|
label: 'Phase 1 — Foundation',
|
||||||
description:
|
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: [
|
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: '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: '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: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
|
||||||
{ text: 'Real-time console', note: 'NATS-bridged live output' },
|
{ 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: 'Scheduled tasks and maintenance windows' },
|
||||||
{ text: 'Player management and RBAC team access' },
|
{ text: 'Player management and RBAC team access' },
|
||||||
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
|
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
|
||||||
@@ -40,39 +40,47 @@ const groups: RoadmapGroup[] = [
|
|||||||
{ text: 'Discord and notification webhooks' },
|
{ 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',
|
status: 'in-progress',
|
||||||
label: 'Multi-game expansion',
|
label: 'Multi-game Formulae',
|
||||||
description:
|
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: [
|
items: [
|
||||||
{ text: 'Dune: Awakening blueprint', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' },
|
{ text: 'Dune: Awakening Formula', note: 'Battlegroup lifecycle shipped; Deep Desert wipe scheduling in progress' },
|
||||||
{ text: 'Conan Exiles blueprint', note: 'Persistent world management, mod support, purge tracking' },
|
{ text: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' },
|
||||||
{ text: 'Soulmask blueprint', note: 'Linked-world cluster deployment, port automation' },
|
{ text: 'Soulmask Formula', 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' },
|
},
|
||||||
|
{
|
||||||
|
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',
|
status: 'planned',
|
||||||
label: 'API access and integrations',
|
label: 'The Exchange',
|
||||||
description:
|
description:
|
||||||
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane.',
|
'Corrosion\'s native storefront for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
|
||||||
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.',
|
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Item catalog and categories' },
|
{ text: 'Item catalog and categories' },
|
||||||
{ text: 'PayPal and Stripe payment processing' },
|
{ 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' },
|
{ 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.',
|
'Tools for hosting providers and multi-server operations running 50+ instances across multiple physical hosts.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Fleet-level dashboards and health monitoring' },
|
{ 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: '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.',
|
'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: [
|
items: [
|
||||||
{ text: 'Additional survival and sandbox games' },
|
{ text: 'Additional survival and sandbox games' },
|
||||||
{ text: 'Community-requested game blueprints' },
|
{ text: 'Community-requested game Formulae' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user