9 Commits

Author SHA1 Message Date
Vantz Stockwell
57858a1e1c feat(agent): systemd service install/uninstall subcommands (alpha.11)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m34s
Build Host Agent (Rust) / build (push) Successful in 1m44s
CI / integration (push) Successful in 22s
For Saturday's Ubuntu host + Linux VM: 'corrosion-host-agent install' writes a
systemd unit (Type=simple — the agent already handles SIGTERM cleanly),
daemon-reloads, and enables+starts the service; 'uninstall' reverses it.

- new service.rs: pure unit_file_contents() generator (unit-tested) + Linux
  install/uninstall via systemctl; non-Linux returns a clear 'Linux only' error
  (Windows SCM is the follow-up).
- ExecStart honors the resolved --config path (default or explicit).
- Runs as root: the agent supervises game processes + their files, needs broad
  filesystem access.

cargo check + service unit test green. Tag agent-v2.0.0-alpha.11 -> CI signs ->
CDN /host-agent/alpha/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:31:45 -04:00
Vantz Stockwell
5b323137e0 feat(auth): API-key authentication — corr_ bearer key acts as license owner
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 31s
CI / integration (push) Has been skipped
Closes the 'Public REST API' last mile: external callers authenticate with a
per-license API key instead of a JWT. Additive and zero-regression:

- JwtAuthGuard: a corr_-prefixed bearer token (or X-API-Key header) is
  validated via ApiKeysService.validateKey and sets request.user shaped like a
  JWT user, scoped to the key's license. JWTs are eyJ... and never collide with
  the corr_ prefix, so the existing JWT path is byte-for-byte unchanged.
- API-key calls act AS the license owner: validateKey now resolves
  license.owner_user_id so sub is a real UUID — any @CurrentUser/created_by FK
  insert works and attributes correctly. (ApiKeysModule gains the License repo.)
- PermissionsGuard: is_api_key principals get full access to their own license
  (always tenant-scoped). Future: scoped/read-only keys.

Backend tsc green. Untested at runtime (no local DB) — needs a curl smoke test
on Saturday's fresh stack before the roadmap item flips to shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:26:59 -04:00
Vantz Stockwell
4d455918f5 docs(roadmap): check off webhooks + API key management (API access -> in progress)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 21s
Webhook events and per-license API key management shipped (commits 55c9893,
0effaaf, a1768bd). Moved 'API access and integrations' to in-progress with
per-item notes; key-authenticated external API access is the remaining piece.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:22:28 -04:00
Vantz Stockwell
a1768bdd2a feat(wipes): report wipe status from agent reply + wipe_completed webhook; harden webhook delivery against SSRF
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 21s
Wipe status reporting (closes the wipe_history-stays-pending gap):
- triggerWipe now dispatches the wipe non-blocking (a wipe is stop+delete+
  start, up to a minute+) and records the outcome from the agent's reply:
  status -> success/failed, started_at/completed_at, error_message. The row
  used to be created 'pending' and never advance, so history lied.
- On success, fires the third webhook event: 'wipe_completed'
  (server_down + player_banned shipped in 0effaaf).

SSRF hardening (security review HIGH on webhook delivery):
- new common/ssrf-guard.ts: resolve the URL host and reject private /
  loopback / link-local / reserved (v4 + v6, incl. 169.254.169.254 metadata,
  IPv4-mapped, fc00::/7, fe80::/10). http/https only.
- Applied at storage (create/update -> early 400) AND immediately before each
  delivery (DNS-rebinding/TOCTOU). fetch uses redirect:'manual' so a 3xx
  can't bounce delivery to an internal host; a redirect is a failed delivery.
- Verified IP range math + IPv6 bracket-strip (URL keeps '[::1]') empirically.

Backend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:20:24 -04:00
Vantz Stockwell
0effaaf86c feat(api): outbound webhooks — server_down + player_banned events
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
Roadmap 'Webhook events': per-license outbound webhooks with HMAC-SHA256
signatures (X-Corrosion-Signature), 5s timeout, fire-and-forget (a webhook
failure never breaks the triggering action), last_delivery_at/last_status
tracked.

- migration 024_webhooks; Webhook entity (events as simple-array);
  WebhooksModule (@Global, exports WebhooksService) wired into app.module;
  CRUD controller (license-scoped, webhooks.view/manage).
- Hooked events: players.performAction ban -> 'player_banned';
  host-agent-consumer going-offline + staleness sweep -> 'server_down'.
- 'wipe_completed' event lands next (needs wipe status from the agent reply).

Backend tsc green. Migration applies on a fresh DB (Saturday).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:13:13 -04:00
Vantz Stockwell
55c9893131 feat(api): per-license API key management + roadmap sync
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 46s
CI / integration (push) Successful in 22s
API keys (roadmap: 'API key management per license'):
- migration 023_api_keys; ApiKey entity; ApiKeysModule (@Global, exports
  ApiKeysService) wired into app.module.
- Service: create (corr_<prefix>_<secret>, returns plaintext once, stores
  sha256 hash + prefix), list (no hash), revoke, and validateKey(rawKey) ->
  { license_id } for a future API-key auth guard. Controller license-scoped +
  RBAC (apikeys.view/manage).

Roadmap: moved the shipped multi-game items (multi-instance host runtime,
per-game wipe + event scheduling) into a 'Phase 2 — Multi-game runtime' shipped
group; Dune/Conan/Soulmask Formulae stay in-progress.

Backend tsc + frontend build green. Migration applies on a fresh DB (Saturday host).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:04:41 -04:00
Vantz Stockwell
62bc9cd2a3 feat(wipes): wire the auto-wiper — scheduled wipes now actually fire
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
wipe_schedules rows existed but nothing read or fired them — an operator could
set a wipe schedule and it would never trigger (the headline auto-wipe feature
was inert; the manual trigger worked, the scheduler did not).

- WipesService now implements OnModuleInit/OnModuleDestroy with a 60s executor
  (mirrors SchedulesService): bootstraps next_scheduled_run, then fires every
  active schedule whose next_scheduled_run <= now via triggerWipe(...'scheduled')
  -> instancesService.wipeForLicense -> the agent wipe handler, advancing
  next_scheduled_run from the cron each cycle (advances even on failure so a
  broken schedule can't re-fire every 60s).
- triggerWipe parameterized with triggerType ('manual' | 'scheduled') so
  wipe_history records the real origin.
- Extracted nextCronDate into src/common/cron.util.ts (shared by the event and
  wipe schedulers; was duplicated/private). Cron is evaluated UTC — the per-
  schedule timezone column is still not honored, a known limitation shared by
  both schedulers (follow-up: tz-aware cron lib).

Backend tsc green. Scheduling logic is at parity with the in-production event
scheduler; live end-to-end (a scheduled wipe deleting real files) verifies when
a game stack + agent are connected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:50:49 -04:00
Vantz Stockwell
e23b6a7e69 feat(brand): chemistry rebrand across panel + marketing
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 34s
CI / integration (push) Has been skipped
The logged-in panel is now Catalyst Console (by Corrosion); the marketing site
keeps Corrosion as the platform/company and introduces the lexicon.

- Wordmark: panel/auth Logo lockup -> 'Catalyst' / 'by Corrosion'; the shared
  C-core house mark (CorrosionMark) is untouched. Marketing nav/footer keep the
  'Corrosion' wordmark.
- Titles: panel routes -> '{View} · Catalyst'; auth -> Catalyst; document.title
  fallback + index.html -> 'Catalyst Console'. Marketing titles stay '— Corrosion'.
- Host agent user-facing copy -> 're-Agent' across panel + marketing (the
  binary filename / CDN URLs / config paths / domains are UNCHANGED — that's the
  separate infra/binary-rename sprint; 'Download re-Agent' fetching
  corrosion-host-agent-* is the intended intermediate state).
- Deploy-recipe 'blueprint/template' -> 'Formula/Formulae' in marketing + roadmap;
  Rust in-game 'blueprint wipe' kept (game term).
- docs/BRANDING.md added (Oracle review + locked lexicon).

vue-tsc + vite green; rendered clean both faces (Catalyst panel / Corrosion
marketing), 0 console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:19:01 -04:00
Vantz Stockwell
215355d1cb fix(security): prevent RCON command injection in player kick/ban/unban (HIGH)
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 29s
CI / integration (push) Has been skipped
Player id and ban reason flowed unsanitized into the single-line RCON command,
so a control char (newline/CR) in 'reason' could break the framing and inject a
second console command — an RBAC-escalation vector (a Moderator-role user could
run arbitrary RCON via the ban reason field).

- validate player id against a safe token charset /^[A-Za-z0-9_.:-]{1,64}$/ and
  reject otherwise (multi-game safe — not a Rust-only SteamID64 regex, so
  Conan/Funcom and Dune ids still pass)
- strip C0 control chars from reason, collapse whitespace, cap at 200 chars
- coerce ban duration to a non-negative integer

Flagged by automated commit security review. Backend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:36:44 -04:00
48 changed files with 1652 additions and 226 deletions

View File

@@ -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)

View 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;
}

View File

@@ -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;
} }
} }

View File

@@ -19,6 +19,11 @@ 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;

View 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;
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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 };
}
}

View 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;
}

View File

@@ -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 '';
} }

View File

@@ -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 {

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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,
};
}
}

View File

@@ -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 };
} }

View File

@@ -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.
});
}
} }
/** /**

View 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);

View 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);

View File

@@ -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",

View File

@@ -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"

View File

@@ -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;

View File

@@ -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);

View 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
View 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.

View File

@@ -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." />

View File

@@ -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="{

View File

@@ -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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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')

View File

@@ -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>

View File

@@ -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')">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&#x23; Write /etc/corrosion/agent.toml (see config block below), then run:</p> <p class="sv__cmt sv__mt">&#x23; 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">&#x23; Write C:\ProgramData\Corrosion\agent.toml (see config block below), then run:</p> <p class="sv__cmt sv__mt">&#x23; 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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -28,12 +28,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 +54,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 +64,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 +74,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 +82,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 +100,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 +110,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?',

View File

@@ -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

View File

@@ -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 &amp; Linux hosts Windows &amp; 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>

View File

@@ -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>

View File

@@ -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' },
@@ -41,27 +41,35 @@ const groups: RoadmapGroup[] = [
], ],
}, },
{ {
status: 'in-progress', status: 'shipped',
label: 'Multi-game expansion', label: 'Phase 2 — Multi-game runtime',
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.', '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: [ items: [
{ text: 'Dune: Awakening blueprint', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' }, { text: 'Multi-instance host runtime', note: 'One re-Agent managing N game processes on the same machine' },
{ text: 'Conan Exiles blueprint', note: 'Persistent world management, mod support, purge tracking' }, { text: 'Per-game wipe and event scheduling', note: 'Auto-wiper and event scheduler both fire per-game instance' },
{ text: 'Soulmask blueprint', note: 'Linked-world cluster deployment, port automation' },
{ text: 'Multi-instance host runtime', note: 'One agent managing N game processes on the same machine' },
{ text: 'Per-game wipe and event scheduling' },
], ],
}, },
{ {
status: 'planned', status: 'in-progress',
label: 'Multi-game expansion — game Formulae',
description:
'Per-game Formulae extend the control plane with game-specific operational logic. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model.',
items: [
{ text: 'Dune: Awakening Formula', note: 'Battlegroup lifecycle shipped; Deep Desert wipe scheduling in progress' },
{ text: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' },
{ text: 'Soulmask Formula', note: 'Linked-world cluster deployment, port automation' },
],
},
{
status: 'in-progress',
label: 'API access and integrations', label: 'API access and integrations',
description: description:
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane.', '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: [ items: [
{ text: 'Public REST API for server management' }, { 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)' }, { text: 'Webhook events (wipe completed, server down, player banned)', note: 'Shipped — HMAC-SHA256 signed delivery, SSRF-guarded' },
{ text: 'API key management per license' }, { text: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' },
], ],
}, },
{ {
@@ -72,7 +80,7 @@ const groups: RoadmapGroup[] = [
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,7 +91,7 @@ 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' },
], ],
@@ -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' },
], ],
}, },
] ]