Compare commits
9 Commits
agent-v2.0
...
agent-v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57858a1e1c | ||
|
|
5b323137e0 | ||
|
|
4d455918f5 | ||
|
|
a1768bdd2a | ||
|
|
0effaaf86c | ||
|
|
55c9893131 | ||
|
|
62bc9cd2a3 | ||
|
|
e23b6a7e69 | ||
|
|
215355d1cb |
@@ -47,6 +47,8 @@ import { RaidableBasesModule } from './modules/raidablebases/raidablebases.modul
|
|||||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
import { FleetModule } from './modules/fleet/fleet.module';
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
import { InstancesModule } from './modules/instances/instances.module';
|
import { InstancesModule } from './modules/instances/instances.module';
|
||||||
|
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
|
||||||
|
import { WebhooksModule } from './modules/webhooks/webhooks.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -137,6 +139,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
EarlyAccessModule,
|
EarlyAccessModule,
|
||||||
FleetModule,
|
FleetModule,
|
||||||
InstancesModule,
|
InstancesModule,
|
||||||
|
ApiKeysModule,
|
||||||
|
WebhooksModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
|
|||||||
51
backend-nest/src/common/cron.util.ts
Normal file
51
backend-nest/src/common/cron.util.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Minimal 5-field cron "next run" calculator, shared by the event scheduler
|
||||||
|
* (SchedulesService) and the wipe scheduler (WipesService).
|
||||||
|
*
|
||||||
|
* Supports `*` and exact numeric fields (minute hour day-of-month month
|
||||||
|
* day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a
|
||||||
|
* malformed expression or if no match is found within a year.
|
||||||
|
*
|
||||||
|
* NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone`
|
||||||
|
* column exists on both schedule tables but is NOT yet honored here — fixing it
|
||||||
|
* properly needs a timezone-aware cron library; tracked as a shared follow-up.
|
||||||
|
*/
|
||||||
|
export function nextCronDate(expr: string, after: Date): Date | null {
|
||||||
|
const parts = expr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return null;
|
||||||
|
|
||||||
|
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||||
|
|
||||||
|
const matches = (e: string, value: number): boolean => {
|
||||||
|
if (e === '*') return true;
|
||||||
|
return parseInt(e, 10) === value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk minute-by-minute up to 366 days forward to find the next match.
|
||||||
|
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||||
|
candidate.setSeconds(0, 0);
|
||||||
|
|
||||||
|
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
while (candidate < limit) {
|
||||||
|
const min = candidate.getUTCMinutes();
|
||||||
|
const hour = candidate.getUTCHours();
|
||||||
|
const dom = candidate.getUTCDate();
|
||||||
|
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||||
|
const dow = candidate.getUTCDay(); // 0=Sun
|
||||||
|
|
||||||
|
if (
|
||||||
|
matches(minuteExpr, min) &&
|
||||||
|
matches(hourExpr, hour) &&
|
||||||
|
matches(domExpr, dom) &&
|
||||||
|
matches(monthExpr, month) &&
|
||||||
|
matches(dowExpr, dow)
|
||||||
|
) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.setTime(candidate.getTime() + 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,20 +1,68 @@
|
|||||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private reflector: Reflector) {
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private readonly apiKeysService: ApiKeysService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (isPublic) return true;
|
if (isPublic) return true;
|
||||||
return super.canActivate(context);
|
|
||||||
|
// Additive API-key auth: a `corr_`-prefixed bearer token (or X-API-Key
|
||||||
|
// header) authenticates programmatically AS the license owner. JWTs are
|
||||||
|
// `eyJ...` and never collide with the `corr_` prefix, so the standard JWT
|
||||||
|
// path below is left completely untouched — zero login regression risk.
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const rawKey = this.extractApiKey(request);
|
||||||
|
if (rawKey) {
|
||||||
|
const result = await this.apiKeysService.validateKey(rawKey);
|
||||||
|
if (!result) {
|
||||||
|
throw new UnauthorizedException('Invalid or revoked API key');
|
||||||
|
}
|
||||||
|
// Shape the principal like a JWT user so @CurrentTenant / @CurrentUser and
|
||||||
|
// the permission layer behave identically. is_api_key grants full access
|
||||||
|
// to THIS license (see PermissionsGuard) — a key is full programmatic
|
||||||
|
// access to your own license, always tenant-scoped by license_id.
|
||||||
|
request.user = {
|
||||||
|
sub: result.user_id ?? undefined,
|
||||||
|
license_id: result.license_id,
|
||||||
|
is_super_admin: false,
|
||||||
|
is_api_key: true,
|
||||||
|
permissions: {},
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await super.canActivate(context)) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a `corr_`-prefixed key from `Authorization: Bearer` or `X-API-Key`. */
|
||||||
|
private extractApiKey(request: any): string | null {
|
||||||
|
const auth = request.headers?.authorization;
|
||||||
|
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||||
|
const token = auth.slice(7).trim();
|
||||||
|
if (token.startsWith('corr_')) return token;
|
||||||
|
}
|
||||||
|
const headerKey = request.headers?.['x-api-key'];
|
||||||
|
if (typeof headerKey === 'string' && headerKey.startsWith('corr_')) {
|
||||||
|
return headerKey.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,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;
|
||||||
|
|||||||
100
backend-nest/src/common/ssrf-guard.ts
Normal file
100
backend-nest/src/common/ssrf-guard.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF guard for operator-supplied outbound URLs (webhooks today; any future
|
||||||
|
* "we POST to a URL you give us" feature should reuse this).
|
||||||
|
*
|
||||||
|
* The danger: an operator (or anyone who can create a webhook) points the URL at
|
||||||
|
* an internal address — 127.0.0.1, the NATS/DB ports, 192.168.x, or the cloud
|
||||||
|
* metadata endpoint 169.254.169.254 — and turns our server into a request proxy
|
||||||
|
* into the private network. We defend by resolving the host and refusing any
|
||||||
|
* private / loopback / link-local / reserved destination.
|
||||||
|
*
|
||||||
|
* Validate at storage (early, clear 400) AND immediately before each delivery
|
||||||
|
* (a hostname can resolve public at create time and private at send time — DNS
|
||||||
|
* rebinding / TOCTOU). `redirect: 'manual'` at the fetch call closes the
|
||||||
|
* redirect-bounce variant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function isBlockedIpv4(ip: string): boolean {
|
||||||
|
const parts = ip.split('.').map((p) => parseInt(p, 10));
|
||||||
|
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
||||||
|
return true; // unparseable → block defensively
|
||||||
|
}
|
||||||
|
const [a, b] = parts;
|
||||||
|
if (a === 0) return true; // 0.0.0.0/8 "this network"
|
||||||
|
if (a === 10) return true; // 10.0.0.0/8 private
|
||||||
|
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||||
|
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
||||||
|
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
||||||
|
if (a === 255) return true; // 255.x broadcast space
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6(ip: string): boolean {
|
||||||
|
const addr = ip.toLowerCase();
|
||||||
|
// IPv4-mapped (::ffff:1.2.3.4) — unwrap and apply the v4 rules.
|
||||||
|
const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (mapped) return isBlockedIpv4(mapped[1]);
|
||||||
|
if (addr === '::' || addr === '::1') return true; // unspecified / loopback
|
||||||
|
const head = addr.split(':')[0];
|
||||||
|
if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 ULA
|
||||||
|
if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIp(ip: string): boolean {
|
||||||
|
const fam = isIP(ip);
|
||||||
|
if (fam === 4) return isBlockedIpv4(ip);
|
||||||
|
if (fam === 6) return isBlockedIpv6(ip);
|
||||||
|
return true; // not a recognizable IP → block defensively
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse + require http/https scheme. Throws BadRequestException on anything else. */
|
||||||
|
export function parseHttpUrl(raw: string): URL {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Webhook URL is not a valid URL');
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
throw new BadRequestException('Webhook URL must use http:// or https://');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the host and reject if it maps to any private / reserved address.
|
||||||
|
* If a hostname resolves to multiple addresses, ANY blocked one rejects the
|
||||||
|
* whole URL (a DNS-rebinding response that mixes a public and a private answer
|
||||||
|
* must not slip through). Returns the parsed URL on success.
|
||||||
|
*/
|
||||||
|
export async function assertPublicHttpUrl(raw: string): Promise<URL> {
|
||||||
|
const url = parseHttpUrl(raw);
|
||||||
|
// URL keeps IPv6 literals bracketed ("[::1]") — strip so isIP/lookup see the
|
||||||
|
// bare address; otherwise IPv6 literals never reach the classifier.
|
||||||
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
|
||||||
|
let addresses: Array<{ address: string }>;
|
||||||
|
if (isIP(host)) {
|
||||||
|
addresses = [{ address: host }];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
addresses = await lookup(host, { all: true });
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Webhook host could not be resolved: ${host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.length === 0 || addresses.some((a) => isBlockedIp(a.address))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Webhook URL resolves to a private or reserved address and is not allowed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
37
backend-nest/src/entities/api-key.entity.ts
Normal file
37
backend-nest/src/entities/api-key.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('api_keys')
|
||||||
|
@Index(['key_hash'])
|
||||||
|
@Index(['license_id'])
|
||||||
|
export class ApiKey {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** First 8 chars of the random token — shown in UI so users can identify keys. */
|
||||||
|
@Column({ type: 'varchar', length: 16 })
|
||||||
|
key_prefix: string;
|
||||||
|
|
||||||
|
/** SHA-256 hex digest of the full plaintext key. Never returned to clients. */
|
||||||
|
@Column({ type: 'varchar', length: 128 })
|
||||||
|
key_hash: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_used_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
47
backend-nest/src/entities/webhook.entity.ts
Normal file
47
backend-nest/src/entities/webhook.entity.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('webhooks')
|
||||||
|
@Index(['license_id'])
|
||||||
|
export class Webhook {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated event keys stored as plain text in Postgres.
|
||||||
|
* TypeORM simple-array serialises string[] ↔ 'event1,event2' automatically.
|
||||||
|
*/
|
||||||
|
@Column({ type: 'simple-array' })
|
||||||
|
events: string[];
|
||||||
|
|
||||||
|
/** HMAC-SHA256 signing secret. Auto-generated on create if omitted. */
|
||||||
|
@Column({ type: 'varchar', length: 128 })
|
||||||
|
secret: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
/** Timestamp of the most recent delivery attempt (success or failure). */
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_delivery_at: Date | null;
|
||||||
|
|
||||||
|
/** 'ok' | 'failed' — outcome of the most recent delivery attempt. */
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
last_status: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
|
||||||
|
@ApiTags('api-keys')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('api-keys')
|
||||||
|
export class ApiKeysController {
|
||||||
|
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('apikeys.manage')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Create an API key',
|
||||||
|
description:
|
||||||
|
'Issues a new API key for this license. The full plaintext key is returned ONCE — store it securely; it cannot be retrieved again.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Key created — plaintext key returned once.' })
|
||||||
|
async create(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Body() dto: CreateApiKeyDto,
|
||||||
|
) {
|
||||||
|
return this.apiKeysService.create(licenseId, dto.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('apikeys.view')
|
||||||
|
@ApiOperation({ summary: 'List API keys', description: 'Returns all keys (active and revoked) for this license. Key hashes are never returned.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Key list.' })
|
||||||
|
async list(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.apiKeysService.list(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermission('apikeys.manage')
|
||||||
|
@ApiOperation({ summary: 'Revoke an API key', description: 'Soft-deletes the key (is_active = false). The row is retained for audit purposes.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Key revoked.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Key not found in this license.' })
|
||||||
|
async revoke(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.apiKeysService.revoke(licenseId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
15
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ApiKey } from '../../entities/api-key.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ApiKey, License])],
|
||||||
|
controllers: [ApiKeysController],
|
||||||
|
providers: [ApiKeysService],
|
||||||
|
exports: [ApiKeysService],
|
||||||
|
})
|
||||||
|
export class ApiKeysModule {}
|
||||||
163
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
163
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { ApiKey } from '../../entities/api-key.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
|
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
|
||||||
|
export interface CreatedApiKey {
|
||||||
|
/** Full plaintext key — show once, store nowhere. */
|
||||||
|
plaintext_key: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safe list view — no hash, no plaintext. */
|
||||||
|
export interface ApiKeyListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeysService {
|
||||||
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApiKey)
|
||||||
|
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||||
|
@InjectRepository(License)
|
||||||
|
private readonly licenseRepo: Repository<License>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a new API key for the given license.
|
||||||
|
*
|
||||||
|
* Key format: `corr_<prefix8>_<secret32>`
|
||||||
|
* where prefix and secret are URL-safe base64url random bytes.
|
||||||
|
*
|
||||||
|
* Returns the full plaintext key ONCE alongside the saved row.
|
||||||
|
* The hash is never returned to the caller.
|
||||||
|
*/
|
||||||
|
async create(licenseId: string, name: string): Promise<CreatedApiKey> {
|
||||||
|
const prefixBytes = crypto.randomBytes(6); // 8 base64url chars
|
||||||
|
const secretBytes = crypto.randomBytes(24); // 32 base64url chars
|
||||||
|
|
||||||
|
const prefix = prefixBytes.toString('base64url');
|
||||||
|
const secret = secretBytes.toString('base64url');
|
||||||
|
const plaintextKey = `corr_${prefix}_${secret}`;
|
||||||
|
|
||||||
|
const keyHash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(plaintextKey)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const entity = this.apiKeyRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
name,
|
||||||
|
key_prefix: prefix,
|
||||||
|
key_hash: keyHash,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.apiKeyRepo.save(entity);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`API key created: id=${saved.id} prefix=${prefix} license=${licenseId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plaintext_key: plaintextKey,
|
||||||
|
id: saved.id,
|
||||||
|
name: saved.name,
|
||||||
|
key_prefix: saved.key_prefix,
|
||||||
|
is_active: saved.is_active,
|
||||||
|
created_at: saved.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all keys (active and revoked) for a license.
|
||||||
|
* The key_hash is intentionally excluded.
|
||||||
|
*/
|
||||||
|
async list(licenseId: string): Promise<ApiKeyListItem[]> {
|
||||||
|
const rows = await this.apiKeyRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
select: ['id', 'name', 'key_prefix', 'last_used_at', 'is_active', 'created_at'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
key_prefix: r.key_prefix,
|
||||||
|
last_used_at: r.last_used_at,
|
||||||
|
is_active: r.is_active,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (soft-delete) a key.
|
||||||
|
* Returns the updated row or throws NotFoundException if the key
|
||||||
|
* doesn't exist within this license.
|
||||||
|
*/
|
||||||
|
async revoke(licenseId: string, id: string): Promise<{ id: string; is_active: boolean }> {
|
||||||
|
const key = await this.apiKeyRepo.findOne({
|
||||||
|
where: { id, license_id: licenseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new NotFoundException(`API key ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
key.is_active = false;
|
||||||
|
await this.apiKeyRepo.save(key);
|
||||||
|
|
||||||
|
this.logger.log(`API key revoked: id=${id} license=${licenseId}`);
|
||||||
|
|
||||||
|
return { id: key.id, is_active: key.is_active };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a raw API key string. Called by JwtAuthGuard.
|
||||||
|
*
|
||||||
|
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves
|
||||||
|
* the license owner (so the guard can attribute the call to a real user UUID),
|
||||||
|
* and returns { license_id, user_id } on success or null on failure.
|
||||||
|
*
|
||||||
|
* user_id is the license owner — API-key calls act AS the owner, so any
|
||||||
|
* created_by / @CurrentUser FK insert gets a valid UUID and correct attribution.
|
||||||
|
*/
|
||||||
|
async validateKey(
|
||||||
|
rawKey: string,
|
||||||
|
): Promise<{ license_id: string; user_id: string | null } | null> {
|
||||||
|
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||||
|
|
||||||
|
const key = await this.apiKeyRepo.findOne({
|
||||||
|
where: { key_hash: keyHash, is_active: true },
|
||||||
|
select: ['id', 'license_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used_at without loading the full row again.
|
||||||
|
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
|
||||||
|
|
||||||
|
const license = await this.licenseRepo.findOne({
|
||||||
|
where: { id: key.license_id },
|
||||||
|
select: ['id', 'owner_user_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { license_id: key.license_id, user_id: license?.owner_user_id ?? null };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateApiKeyDto {
|
||||||
|
@ApiProperty({ description: 'Human-readable label for this key', maxLength: 100 })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PlayerAction } from '../../entities/player-action.entity';
|
import { PlayerAction } from '../../entities/player-action.entity';
|
||||||
import { PlayerSession } from '../../entities/player-session.entity';
|
import { PlayerSession } from '../../entities/player-session.entity';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { PlayerActionDto } from './dto/player-action.dto';
|
import { PlayerActionDto } from './dto/player-action.dto';
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
@@ -24,6 +25,7 @@ export class PlayersService {
|
|||||||
@InjectRepository(PlayerSession)
|
@InjectRepository(PlayerSession)
|
||||||
private readonly sessionRepo: Repository<PlayerSession>,
|
private readonly sessionRepo: Repository<PlayerSession>,
|
||||||
private readonly instancesService: InstancesService,
|
private readonly instancesService: InstancesService,
|
||||||
|
private readonly webhooksService: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,18 +140,52 @@ export class PlayersService {
|
|||||||
await this.instancesService.rconForLicense(licenseId, rconCmd);
|
await this.instancesService.rconForLicense(licenseId, rconCmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire webhook event for player bans. Fire-and-forget — a delivery failure
|
||||||
|
// must never surface to the caller or roll back the ban action.
|
||||||
|
if (dto.action_type === 'ban') {
|
||||||
|
void this.webhooksService
|
||||||
|
.dispatch(licenseId, 'player_banned', {
|
||||||
|
steam_id: dto.steam_id,
|
||||||
|
player_name: dto.player_name,
|
||||||
|
reason: dto.reason ?? null,
|
||||||
|
duration_minutes: dto.duration_minutes ?? null,
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// dispatch() already logs internally; swallow here to guarantee
|
||||||
|
// the ban action result is unaffected.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRconCommand(dto: PlayerActionDto): string {
|
private buildRconCommand(dto: PlayerActionDto): string {
|
||||||
|
// Defense-in-depth against RCON command injection. The command is a single
|
||||||
|
// line; an id or reason containing a newline/control char could break the
|
||||||
|
// framing and inject a second console command. So:
|
||||||
|
// - the player id must be a safe token (no whitespace/control chars) — a
|
||||||
|
// permissive charset, not a Rust-only SteamID64 regex, so Conan (Funcom)
|
||||||
|
// and Dune ids still validate. Reject outright if not.
|
||||||
|
// - the free-text reason has control chars stripped and is length-capped.
|
||||||
|
// - duration is coerced to a non-negative integer.
|
||||||
|
const id = dto.steam_id ?? '';
|
||||||
|
if (!/^[A-Za-z0-9_.:-]{1,64}$/.test(id)) {
|
||||||
|
throw new BadRequestException('Invalid player id');
|
||||||
|
}
|
||||||
|
const safeReason =
|
||||||
|
(dto.reason ?? 'banned').replace(/[\u0000-\u001F]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || 'banned';
|
||||||
|
const secs = Number.isFinite(dto.duration_minutes)
|
||||||
|
? Math.max(0, Math.floor((dto.duration_minutes as number) * 60))
|
||||||
|
: 0;
|
||||||
|
|
||||||
switch (dto.action_type) {
|
switch (dto.action_type) {
|
||||||
case 'kick':
|
case 'kick':
|
||||||
return `kick ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
|
return `kick ${id}${dto.reason ? ' ' + safeReason : ''}`;
|
||||||
case 'ban':
|
case 'ban':
|
||||||
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
|
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
|
||||||
return `banid ${dto.steam_id} ${dto.reason ?? 'banned'} ${dto.duration_minutes ? dto.duration_minutes * 60 : 0}`;
|
return `banid ${id} ${safeReason} ${secs}`;
|
||||||
case 'unban':
|
case 'unban':
|
||||||
return `unban ${dto.steam_id}`;
|
return `unban ${id}`;
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,47 +11,7 @@ import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
|||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { nextCronDate } from '../../common/cron.util';
|
||||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
|
||||||
function nextCronDate(expr: string, after: Date): Date | null {
|
|
||||||
const parts = expr.trim().split(/\s+/);
|
|
||||||
if (parts.length !== 5) return null;
|
|
||||||
|
|
||||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
|
||||||
|
|
||||||
function matches(expr: string, value: number): boolean {
|
|
||||||
if (expr === '*') return true;
|
|
||||||
return parseInt(expr, 10) === value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
|
||||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
|
||||||
candidate.setSeconds(0, 0);
|
|
||||||
|
|
||||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
while (candidate < limit) {
|
|
||||||
const min = candidate.getUTCMinutes();
|
|
||||||
const hour = candidate.getUTCHours();
|
|
||||||
const dom = candidate.getUTCDate();
|
|
||||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
|
||||||
const dow = candidate.getUTCDay(); // 0=Sun
|
|
||||||
|
|
||||||
if (
|
|
||||||
matches(minuteExpr, min) &&
|
|
||||||
matches(hourExpr, hour) &&
|
|
||||||
matches(domExpr, dom) &&
|
|
||||||
matches(monthExpr, month) &&
|
|
||||||
matches(dowExpr, dow)
|
|
||||||
) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
candidate.setTime(candidate.getTime() + 60_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|||||||
33
backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts
Normal file
33
backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsUrl, IsArray, ArrayNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateWebhookDto {
|
||||||
|
@ApiProperty({ description: 'Human-readable label for this webhook', maxLength: 100 })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'HTTPS URL to POST events to' })
|
||||||
|
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Event keys to subscribe to',
|
||||||
|
example: ['player_banned', 'server_down'],
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
events: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'HMAC-SHA256 signing secret. Auto-generated if omitted.',
|
||||||
|
maxLength: 128,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
secret?: string;
|
||||||
|
}
|
||||||
31
backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts
Normal file
31
backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { IsString, IsUrl, IsArray, ArrayNotEmpty, IsOptional, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateWebhookDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Human-readable label for this webhook', maxLength: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'HTTPS URL to POST events to' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Event keys to subscribe to',
|
||||||
|
example: ['player_banned', 'server_down'],
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
events?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Enable or disable this webhook' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
70
backend-nest/src/modules/webhooks/webhooks.controller.ts
Normal file
70
backend-nest/src/modules/webhooks/webhooks.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { WebhooksService } from './webhooks.service';
|
||||||
|
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||||
|
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
|
||||||
|
@ApiTags('webhooks')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('webhooks')
|
||||||
|
export class WebhooksController {
|
||||||
|
constructor(private readonly webhooksService: WebhooksService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('webhooks.manage')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Create a webhook',
|
||||||
|
description:
|
||||||
|
'Registers a new outbound webhook for this license. A signing secret is auto-generated if not provided.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Webhook created.' })
|
||||||
|
async create(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Body() dto: CreateWebhookDto,
|
||||||
|
) {
|
||||||
|
return this.webhooksService.create(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('webhooks.view')
|
||||||
|
@ApiOperation({ summary: 'List webhooks', description: 'Returns all webhooks for this license.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook list.' })
|
||||||
|
async list(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.webhooksService.list(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('webhooks.manage')
|
||||||
|
@ApiOperation({ summary: 'Update a webhook', description: 'Update name, URL, event subscriptions, or active state.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook updated.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
|
||||||
|
async update(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateWebhookDto,
|
||||||
|
) {
|
||||||
|
return this.webhooksService.update(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermission('webhooks.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete a webhook' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook deleted.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
|
||||||
|
async remove(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.webhooksService.remove(licenseId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/webhooks/webhooks.module.ts
Normal file
14
backend-nest/src/modules/webhooks/webhooks.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Webhook } from '../../entities/webhook.entity';
|
||||||
|
import { WebhooksController } from './webhooks.controller';
|
||||||
|
import { WebhooksService } from './webhooks.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Webhook])],
|
||||||
|
controllers: [WebhooksController],
|
||||||
|
providers: [WebhooksService],
|
||||||
|
exports: [WebhooksService],
|
||||||
|
})
|
||||||
|
export class WebhooksModule {}
|
||||||
236
backend-nest/src/modules/webhooks/webhooks.service.ts
Normal file
236
backend-nest/src/modules/webhooks/webhooks.service.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { Webhook } from '../../entities/webhook.entity';
|
||||||
|
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||||
|
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||||
|
import { assertPublicHttpUrl } from '../../common/ssrf-guard';
|
||||||
|
|
||||||
|
/** Safe list view — secret is included (operator's own resource). */
|
||||||
|
export interface WebhookListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
secret: string;
|
||||||
|
is_active: boolean;
|
||||||
|
last_delivery_at: Date | null;
|
||||||
|
last_status: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned on create — identical to list item. */
|
||||||
|
export type CreatedWebhook = WebhookListItem;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebhooksService {
|
||||||
|
private readonly logger = new Logger(WebhooksService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Webhook)
|
||||||
|
private readonly webhookRepo: Repository<Webhook>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async create(licenseId: string, dto: CreateWebhookDto): Promise<CreatedWebhook> {
|
||||||
|
// SSRF guard: reject URLs resolving to private/reserved space before storing.
|
||||||
|
await assertPublicHttpUrl(dto.url);
|
||||||
|
|
||||||
|
// Generate a secret if the caller didn't supply one.
|
||||||
|
const secret = dto.secret ?? crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const entity = this.webhookRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
name: dto.name,
|
||||||
|
url: dto.url,
|
||||||
|
events: dto.events,
|
||||||
|
secret,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.webhookRepo.save(entity);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`webhook created: id=${saved.id} name="${saved.name}" events=[${saved.events.join(',')}] license=${licenseId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.toListItem(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(licenseId: string): Promise<WebhookListItem[]> {
|
||||||
|
const rows = await this.webhookRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
return rows.map(this.toListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(licenseId: string, id: string, dto: UpdateWebhookDto): Promise<WebhookListItem> {
|
||||||
|
const webhook = await this.findOwned(licenseId, id);
|
||||||
|
|
||||||
|
// SSRF guard on any URL change.
|
||||||
|
if (dto.url !== undefined) await assertPublicHttpUrl(dto.url);
|
||||||
|
|
||||||
|
if (dto.name !== undefined) webhook.name = dto.name;
|
||||||
|
if (dto.url !== undefined) webhook.url = dto.url;
|
||||||
|
if (dto.events !== undefined) webhook.events = dto.events;
|
||||||
|
if (dto.is_active !== undefined) webhook.is_active = dto.is_active;
|
||||||
|
|
||||||
|
const saved = await this.webhookRepo.save(webhook);
|
||||||
|
|
||||||
|
this.logger.log(`webhook updated: id=${id} license=${licenseId}`);
|
||||||
|
|
||||||
|
return this.toListItem(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(licenseId: string, id: string): Promise<{ id: string }> {
|
||||||
|
const webhook = await this.findOwned(licenseId, id);
|
||||||
|
await this.webhookRepo.remove(webhook);
|
||||||
|
this.logger.log(`webhook deleted: id=${id} license=${licenseId}`);
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dispatch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire an event to all active webhooks for a license that are subscribed to
|
||||||
|
* the given event key.
|
||||||
|
*
|
||||||
|
* Contract:
|
||||||
|
* - Fire-and-forget: each delivery is attempted with a 5-second AbortController
|
||||||
|
* timeout and never throws out to the caller.
|
||||||
|
* - Each attempt updates last_delivery_at + last_status ('ok' | 'failed').
|
||||||
|
* - The triggering action is NOT blocked. All deliveries run concurrently via
|
||||||
|
* Promise.allSettled; the returned Promise resolves only after all attempts
|
||||||
|
* finish (or time out), so callers can void it for true fire-and-forget.
|
||||||
|
*
|
||||||
|
* Signature header: X-Corrosion-Signature: sha256=<hex>
|
||||||
|
* where hex = HMAC-SHA256(rawBody, webhook.secret).
|
||||||
|
*/
|
||||||
|
async dispatch(
|
||||||
|
licenseId: string,
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
let hooks: Webhook[];
|
||||||
|
try {
|
||||||
|
hooks = await this.webhookRepo.find({
|
||||||
|
where: { license_id: licenseId, is_active: true },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`dispatch: failed to query webhooks for license ${licenseId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to those subscribed to this event.
|
||||||
|
const subscribed = hooks.filter((h) => h.events.includes(event));
|
||||||
|
if (subscribed.length === 0) return;
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
event,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
subscribed.map((hook) => this.deliverOne(hook, event, body)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliver to a single webhook endpoint; update delivery metadata. Never throws. */
|
||||||
|
private async deliverOne(hook: Webhook, event: string, body: string): Promise<void> {
|
||||||
|
const signature = this.sign(body, hook.secret);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 5_000);
|
||||||
|
|
||||||
|
let status: 'ok' | 'failed' = 'failed';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-validate at send time: a host that was public at create time can
|
||||||
|
// resolve to a private address now (DNS rebinding / TOCTOU). Throws → caught
|
||||||
|
// below → recorded 'failed'.
|
||||||
|
await assertPublicHttpUrl(hook.url);
|
||||||
|
|
||||||
|
const res = await fetch(hook.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Corrosion-Signature': `sha256=${signature}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
// Do not auto-follow redirects — a 3xx Location could point at an
|
||||||
|
// internal host, re-opening the SSRF we just closed. A redirect is a
|
||||||
|
// failed delivery here.
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
status = 'ok';
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`webhook delivery failed: id=${hook.id} event=${event} status=${res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message ?? String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`webhook delivery error: id=${hook.id} event=${event} err=${msg}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist delivery outcome — best-effort, never throws.
|
||||||
|
try {
|
||||||
|
await this.webhookRepo.update(hook.id, {
|
||||||
|
last_delivery_at: new Date(),
|
||||||
|
last_status: status,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`webhook metadata update failed: id=${hook.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async findOwned(licenseId: string, id: string): Promise<Webhook> {
|
||||||
|
const webhook = await this.webhookRepo.findOne({
|
||||||
|
where: { id, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!webhook) {
|
||||||
|
throw new NotFoundException(`Webhook ${id} not found`);
|
||||||
|
}
|
||||||
|
return webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sign(body: string, secret: string): string {
|
||||||
|
return crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toListItem(w: Webhook): WebhookListItem {
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
name: w.name,
|
||||||
|
url: w.url,
|
||||||
|
events: w.events,
|
||||||
|
secret: w.secret,
|
||||||
|
is_active: w.is_active,
|
||||||
|
last_delivery_at: w.last_delivery_at,
|
||||||
|
last_status: w.last_status,
|
||||||
|
created_at: w.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||||
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
||||||
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
||||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||||
@@ -9,10 +15,13 @@ import { UpdateProfileDto } from './dto/update-profile.dto';
|
|||||||
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { nextCronDate } from '../../common/cron.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WipesService {
|
export class WipesService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(WipesService.name);
|
private readonly logger = new Logger(WipesService.name);
|
||||||
|
private wipeExecutorInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(WipeProfile)
|
@InjectRepository(WipeProfile)
|
||||||
@@ -22,8 +31,85 @@ export class WipesService {
|
|||||||
@InjectRepository(WipeHistory)
|
@InjectRepository(WipeHistory)
|
||||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||||
private readonly instancesService: InstancesService,
|
private readonly instancesService: InstancesService,
|
||||||
|
private readonly webhooksService: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduled-wipe executor — the auto-wiper. Mirrors SchedulesService: a 60s
|
||||||
|
// poll fires every active wipe schedule whose next_scheduled_run is due, then
|
||||||
|
// advances it from its cron expression. Without this, wipe_schedules rows
|
||||||
|
// never fire (the headline auto-wipe feature was inert).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.bootstrapWipeSchedules().catch((err) =>
|
||||||
|
this.logger.error('Failed to bootstrap wipe-schedule next runs', err),
|
||||||
|
);
|
||||||
|
this.wipeExecutorInterval = setInterval(() => {
|
||||||
|
this.executeDueWipes().catch((err) =>
|
||||||
|
this.logger.error('Wipe-schedule executor error', err),
|
||||||
|
);
|
||||||
|
}, 60_000);
|
||||||
|
this.logger.log('Wipe-schedule executor started (60s polling interval)');
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.wipeExecutorInterval) {
|
||||||
|
clearInterval(this.wipeExecutorInterval);
|
||||||
|
this.wipeExecutorInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On startup, stamp next_scheduled_run on active schedules that lack one. */
|
||||||
|
private async bootstrapWipeSchedules(): Promise<void> {
|
||||||
|
const schedules = await this.wipeScheduleRepo.find({
|
||||||
|
where: { is_active: true, next_scheduled_run: IsNull() },
|
||||||
|
});
|
||||||
|
for (const s of schedules) {
|
||||||
|
const next = nextCronDate(s.cron_expression, new Date());
|
||||||
|
if (next) {
|
||||||
|
s.next_scheduled_run = next;
|
||||||
|
await this.wipeScheduleRepo.save(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schedules.length > 0) {
|
||||||
|
this.logger.log(`Bootstrapped next run for ${schedules.length} wipe schedule(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire every active wipe schedule whose next_scheduled_run <= now. */
|
||||||
|
private async executeDueWipes(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const due = await this.wipeScheduleRepo.find({
|
||||||
|
where: { is_active: true, next_scheduled_run: LessThanOrEqual(now) },
|
||||||
|
});
|
||||||
|
if (due.length === 0) return;
|
||||||
|
|
||||||
|
this.logger.log(`Executing ${due.length} due wipe schedule(s)`);
|
||||||
|
for (const s of due) {
|
||||||
|
try {
|
||||||
|
await this.triggerWipe(
|
||||||
|
s.license_id,
|
||||||
|
{
|
||||||
|
wipe_type: s.wipe_type as TriggerWipeDto['wipe_type'],
|
||||||
|
wipe_profile_id: s.wipe_profile_id,
|
||||||
|
},
|
||||||
|
'scheduled',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Scheduled wipe failed for schedule ${s.id} (${s.schedule_name})`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Advance next_scheduled_run regardless, so a failing schedule doesn't
|
||||||
|
// re-fire every 60s.
|
||||||
|
s.next_scheduled_run = nextCronDate(s.cron_expression, now);
|
||||||
|
await this.wipeScheduleRepo.save(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
||||||
return this.wipeProfileRepo.find({
|
return this.wipeProfileRepo.find({
|
||||||
where: { license_id: licenseId },
|
where: { license_id: licenseId },
|
||||||
@@ -96,19 +182,56 @@ export class WipesService {
|
|||||||
async triggerWipe(
|
async triggerWipe(
|
||||||
licenseId: string,
|
licenseId: string,
|
||||||
dto: TriggerWipeDto,
|
dto: TriggerWipeDto,
|
||||||
|
triggerType: 'manual' | 'scheduled' = 'manual',
|
||||||
): Promise<{ wipe_history_id: string }> {
|
): Promise<{ wipe_history_id: string }> {
|
||||||
const history = this.wipeHistoryRepo.create({
|
const history = this.wipeHistoryRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
wipe_type: dto.wipe_type,
|
wipe_type: dto.wipe_type,
|
||||||
wipe_profile_id: dto.wipe_profile_id,
|
wipe_profile_id: dto.wipe_profile_id,
|
||||||
trigger_type: 'manual',
|
trigger_type: triggerType,
|
||||||
status: 'pending',
|
status: 'wiping',
|
||||||
|
started_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.wipeHistoryRepo.save(history);
|
const saved = await this.wipeHistoryRepo.save(history);
|
||||||
|
this.logger.log(
|
||||||
|
`Wipe ${triggerType} dispatched for license ${licenseId} — history ${saved.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
|
// Dispatch to the agent WITHOUT blocking the caller — a wipe is
|
||||||
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
|
// stop → delete → start and can take a minute+. We record the outcome on
|
||||||
|
// wipe_history from the agent's reply and fire the wipe_completed webhook
|
||||||
|
// when it lands. Previously the row was created 'pending' and never
|
||||||
|
// advanced, so history lied about every wipe.
|
||||||
|
void this.instancesService
|
||||||
|
.wipeForLicense(licenseId, dto.wipe_type, true)
|
||||||
|
.then((reply: unknown) => {
|
||||||
|
const r = (reply ?? {}) as { status?: string; message?: string; deleted_count?: number };
|
||||||
|
const ok = r.status === 'success';
|
||||||
|
saved.status = ok ? 'success' : 'failed';
|
||||||
|
saved.completed_at = new Date();
|
||||||
|
if (!ok) {
|
||||||
|
saved.error_message = r.message ?? 'agent reported wipe failure';
|
||||||
|
}
|
||||||
|
return this.wipeHistoryRepo.save(saved).then(() => {
|
||||||
|
this.logger.log(`Wipe ${saved.id} ${saved.status}`);
|
||||||
|
if (ok) {
|
||||||
|
void this.webhooksService.dispatch(licenseId, 'wipe_completed', {
|
||||||
|
wipe_history_id: saved.id,
|
||||||
|
wipe_type: dto.wipe_type,
|
||||||
|
trigger_type: triggerType,
|
||||||
|
deleted_count: r.deleted_count ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
saved.status = 'failed';
|
||||||
|
saved.completed_at = new Date();
|
||||||
|
saved.error_message = err instanceof Error ? err.message : 'wipe dispatch failed';
|
||||||
|
this.logger.warn(`Wipe ${saved.id} failed: ${saved.error_message}`);
|
||||||
|
void this.wipeHistoryRepo.save(saved);
|
||||||
|
});
|
||||||
|
|
||||||
return { wipe_history_id: saved.id };
|
return { wipe_history_id: saved.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ServerConnection } from '../entities/server-connection.entity';
|
|||||||
import { License } from '../entities/license.entity';
|
import { License } from '../entities/license.entity';
|
||||||
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
|
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
|
||||||
import { GameInstance } from '../entities/game-instance.entity';
|
import { GameInstance } from '../entities/game-instance.entity';
|
||||||
|
import { WebhooksService } from '../modules/webhooks/webhooks.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumes Corrosion wire protocol v2 host-agent subjects
|
* Consumes Corrosion wire protocol v2 host-agent subjects
|
||||||
@@ -64,6 +65,7 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
|||||||
private readonly hostRepository: Repository<AgentHost>,
|
private readonly hostRepository: Repository<AgentHost>,
|
||||||
@InjectRepository(GameInstance)
|
@InjectRepository(GameInstance)
|
||||||
private readonly instanceRepository: Repository<GameInstance>,
|
private readonly instanceRepository: Repository<GameInstance>,
|
||||||
|
private readonly webhooksService: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Bootstrap, not module-init: subscriptions registered before NatsService
|
// Bootstrap, not module-init: subscriptions registered before NatsService
|
||||||
@@ -197,22 +199,52 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
|||||||
{ license_id: licenseId },
|
{ license_id: licenseId },
|
||||||
{ connection_status: 'offline', updated_at: now },
|
{ connection_status: 'offline', updated_at: now },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Capture hostname(s) before flipping status so the webhook payload is useful.
|
||||||
|
const hosts = await this.hostRepository.find({ where: { license_id: licenseId } });
|
||||||
|
|
||||||
await this.hostRepository.update(
|
await this.hostRepository.update(
|
||||||
{ license_id: licenseId },
|
{ license_id: licenseId },
|
||||||
{ status: 'offline', updated_at: now },
|
{ status: 'offline', updated_at: now },
|
||||||
);
|
);
|
||||||
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
|
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
|
||||||
|
|
||||||
|
// Dispatch server_down event for each host that went offline. Fire-and-forget.
|
||||||
|
for (const host of hosts) {
|
||||||
|
void this.webhooksService
|
||||||
|
.dispatch(licenseId, 'server_down', {
|
||||||
|
host_id: host.id,
|
||||||
|
hostname: host.hostname ?? null,
|
||||||
|
reason: 'graceful_shutdown',
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// dispatch() logs internally; swallow here to keep the handler clean.
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heartbeats stopping must flip the panel to offline — an agent that
|
* Heartbeats stopping must flip the panel to offline — an agent that
|
||||||
* crashes or loses network never sends the goodbye beacon. Sweeps both the
|
* crashes or loses network never sends the goodbye beacon. Sweeps both the
|
||||||
* legacy connection and fleet hosts.
|
* legacy connection and fleet hosts.
|
||||||
|
*
|
||||||
|
* Hosts that transition to offline here also fire the server_down webhook.
|
||||||
|
* We identify them BEFORE the bulk update so we can carry their identity
|
||||||
|
* into the webhook payload.
|
||||||
*/
|
*/
|
||||||
@Interval(60_000)
|
@Interval(60_000)
|
||||||
async sweepStaleConnections(): Promise<void> {
|
async sweepStaleConnections(): Promise<void> {
|
||||||
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
|
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
|
||||||
|
|
||||||
|
// Identify stale hosts BEFORE bulk-updating so we can dispatch webhooks
|
||||||
|
// with meaningful host_id / hostname data.
|
||||||
|
const staleHosts = await this.hostRepository
|
||||||
|
.createQueryBuilder('host')
|
||||||
|
.where('host.status = :connected', { connected: 'connected' })
|
||||||
|
.andWhere('host.last_heartbeat_at IS NOT NULL')
|
||||||
|
.andWhere('host.last_heartbeat_at < :threshold', { threshold })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
const conn = await this.connectionRepository
|
const conn = await this.connectionRepository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(ServerConnection)
|
.update(ServerConnection)
|
||||||
@@ -235,6 +267,20 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
|||||||
if (affected) {
|
if (affected) {
|
||||||
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
|
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch server_down webhook for each host that just timed out.
|
||||||
|
// Fire-and-forget — webhook failures must never break the sweep.
|
||||||
|
for (const host of staleHosts) {
|
||||||
|
void this.webhooksService
|
||||||
|
.dispatch(host.license_id, 'server_down', {
|
||||||
|
host_id: host.id,
|
||||||
|
hostname: host.hostname ?? null,
|
||||||
|
reason: 'heartbeat_timeout',
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// dispatch() logs internally; swallow here to keep the sweep clean.
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
17
backend/migrations/023_api_keys.sql
Normal file
17
backend/migrations/023_api_keys.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Per-license API key management
|
||||||
|
-- Each row represents one issued key: the plaintext is shown once at creation
|
||||||
|
-- and never stored; only the SHA-256 hex digest is persisted.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
key_prefix VARCHAR(16) NOT NULL,
|
||||||
|
key_hash VARCHAR(128) NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_license ON api_keys(license_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||||
26
backend/migrations/024_webhooks.sql
Normal file
26
backend/migrations/024_webhooks.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- 024_webhooks.sql
|
||||||
|
-- Per-license outbound webhook registry.
|
||||||
|
-- Operators register URLs + event subscriptions; the backend POSTs signed
|
||||||
|
-- JSON payloads on matching events (player_banned, server_down, …).
|
||||||
|
|
||||||
|
CREATE TABLE webhooks (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
license_id uuid NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||||
|
name varchar(100) NOT NULL,
|
||||||
|
url text NOT NULL,
|
||||||
|
-- Comma-separated event keys, e.g. 'player_banned,server_down'
|
||||||
|
-- TypeORM simple-array maps this transparently to string[].
|
||||||
|
events text NOT NULL,
|
||||||
|
-- HMAC-SHA256 signing secret; generated server-side if omitted on create.
|
||||||
|
secret varchar(128) NOT NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
-- Populated after each delivery attempt.
|
||||||
|
last_delivery_at timestamptz NULL,
|
||||||
|
-- 'ok' | 'failed' — last HTTP delivery outcome.
|
||||||
|
last_status varchar(20) NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT webhooks_pkey PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_webhooks_license_id ON webhooks (license_id);
|
||||||
2
corrosion-host-agent/Cargo.lock
generated
2
corrosion-host-agent/Cargo.lock
generated
@@ -287,7 +287,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.10"
|
version = "2.0.0-alpha.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.10"
|
version = "2.0.0-alpha.11"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||||
license = "UNLICENSED"
|
license = "UNLICENSED"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod instancecmd;
|
|||||||
pub mod prober;
|
pub mod prober;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod rcon;
|
pub mod rcon;
|
||||||
|
pub mod service;
|
||||||
pub mod steamcmd;
|
pub mod steamcmd;
|
||||||
pub mod subjects;
|
pub mod subjects;
|
||||||
pub mod supervisor;
|
pub mod supervisor;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use corrosion_host_agent::{
|
use corrosion_host_agent::{
|
||||||
agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process,
|
agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process,
|
||||||
subjects, supervisor, telemetry, version,
|
service, subjects, supervisor, telemetry, version,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -37,6 +37,10 @@ enum Command {
|
|||||||
Check,
|
Check,
|
||||||
/// Print full version (semver, git hash, build timestamp) and exit.
|
/// Print full version (semver, git hash, build timestamp) and exit.
|
||||||
Version,
|
Version,
|
||||||
|
/// Install as a systemd service and start it (Linux; requires root).
|
||||||
|
Install,
|
||||||
|
/// Stop and remove the systemd service (Linux; requires root).
|
||||||
|
Uninstall,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -58,6 +62,8 @@ fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Some(Command::Install) => service::install(&config_path),
|
||||||
|
Some(Command::Uninstall) => service::uninstall(),
|
||||||
None => {
|
None => {
|
||||||
let settings = config::load(&config_path)?;
|
let settings = config::load(&config_path)?;
|
||||||
init_logging(&settings.log_level);
|
init_logging(&settings.log_level);
|
||||||
|
|||||||
129
corrosion-host-agent/src/service.rs
Normal file
129
corrosion-host-agent/src/service.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//! systemd service installation for the host agent (Linux).
|
||||||
|
//!
|
||||||
|
//! `corrosion-host-agent install` writes a systemd unit pointing at the current
|
||||||
|
//! binary + config, reloads systemd, and enables + starts the service.
|
||||||
|
//! `uninstall` reverses it. Windows SCM support is a follow-up; on non-Linux
|
||||||
|
//! these return a clear "Linux only" error rather than silently doing nothing.
|
||||||
|
//!
|
||||||
|
//! The agent already handles SIGTERM (see main::wait_for_shutdown_signal), so a
|
||||||
|
//! plain `Type=simple` unit gives systemd clean start/stop semantics.
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
pub const SERVICE_NAME: &str = "corrosion-host-agent";
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const UNIT_PATH: &str = "/etc/systemd/system/corrosion-host-agent.service";
|
||||||
|
|
||||||
|
/// Render the systemd unit. Pure (no I/O) so it is unit-testable.
|
||||||
|
pub fn unit_file_contents(exec_path: &str, config_path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"[Unit]\n\
|
||||||
|
Description=Corrosion Host Agent (multi-game ops runtime)\n\
|
||||||
|
Documentation=https://corrosionmgmt.com\n\
|
||||||
|
After=network-online.target\n\
|
||||||
|
Wants=network-online.target\n\
|
||||||
|
\n\
|
||||||
|
[Service]\n\
|
||||||
|
Type=simple\n\
|
||||||
|
ExecStart={exec} --config {cfg}\n\
|
||||||
|
Restart=on-failure\n\
|
||||||
|
RestartSec=5\n\
|
||||||
|
# The agent supervises game-server processes and their files, so it\n\
|
||||||
|
# needs broad filesystem access and runs as root by default.\n\
|
||||||
|
User=root\n\
|
||||||
|
\n\
|
||||||
|
[Install]\n\
|
||||||
|
WantedBy=multi-user.target\n",
|
||||||
|
exec = exec_path,
|
||||||
|
cfg = config_path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn install(config_path: &Path) -> Result<()> {
|
||||||
|
let exec = std::env::current_exe().context("resolving current executable path")?;
|
||||||
|
let exec_str = exec.to_string_lossy();
|
||||||
|
let cfg_str = config_path.to_string_lossy();
|
||||||
|
|
||||||
|
let unit = unit_file_contents(&exec_str, &cfg_str);
|
||||||
|
std::fs::write(UNIT_PATH, unit)
|
||||||
|
.with_context(|| format!("writing {UNIT_PATH} (are you root?)"))?;
|
||||||
|
println!("wrote {UNIT_PATH}");
|
||||||
|
|
||||||
|
run("systemctl", &["daemon-reload"])?;
|
||||||
|
run("systemctl", &["enable", "--now", SERVICE_NAME])?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"service '{SERVICE_NAME}' installed and started.\n \
|
||||||
|
status: systemctl status {SERVICE_NAME}\n \
|
||||||
|
logs: journalctl -u {SERVICE_NAME} -f"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn uninstall() -> Result<()> {
|
||||||
|
// Best-effort stop+disable; don't fail if it isn't currently active.
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args(["disable", "--now", SERVICE_NAME])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
if Path::new(UNIT_PATH).exists() {
|
||||||
|
std::fs::remove_file(UNIT_PATH)
|
||||||
|
.with_context(|| format!("removing {UNIT_PATH} (are you root?)"))?;
|
||||||
|
println!("removed {UNIT_PATH}");
|
||||||
|
}
|
||||||
|
run("systemctl", &["daemon-reload"])?;
|
||||||
|
println!("service '{SERVICE_NAME}' uninstalled.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn run(cmd: &str, args: &[&str]) -> Result<()> {
|
||||||
|
let status = std::process::Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("running {cmd} {}", args.join(" ")))?;
|
||||||
|
if !status.success() {
|
||||||
|
bail!("{cmd} {} failed with {status}", args.join(" "));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn install(_config_path: &Path) -> Result<()> {
|
||||||
|
bail!(
|
||||||
|
"`install` is only supported on Linux (systemd). Windows SCM support is \
|
||||||
|
coming; for now run the agent directly or via your platform's service manager."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn uninstall() -> Result<()> {
|
||||||
|
bail!("`uninstall` is only supported on Linux (systemd).");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_contains_exec_config_and_install_target() {
|
||||||
|
let u = unit_file_contents(
|
||||||
|
"/usr/local/bin/corrosion-host-agent",
|
||||||
|
"/etc/corrosion/agent.toml",
|
||||||
|
);
|
||||||
|
assert!(u.contains(
|
||||||
|
"ExecStart=/usr/local/bin/corrosion-host-agent --config /etc/corrosion/agent.toml"
|
||||||
|
));
|
||||||
|
assert!(u.contains("Type=simple"));
|
||||||
|
assert!(u.contains("Restart=on-failure"));
|
||||||
|
assert!(u.contains("WantedBy=multi-user.target"));
|
||||||
|
assert!(u.contains("After=network-online.target"));
|
||||||
|
}
|
||||||
|
}
|
||||||
156
docs/BRANDING.md
Normal file
156
docs/BRANDING.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Corrosion — Brand & Naming Taxonomy
|
||||||
|
|
||||||
|
**Source:** Oracle (brand review), 2026-06-12.
|
||||||
|
**Status:** Reference / proposal.
|
||||||
|
**Locked (2026-06-12):** **Catalyst Console** (control panel/UI — chosen over "Catalyst Control Panel") · **Substrate** (bare-metal execution / process-automation plane) · **re-Agent** (host node agent, binary `corrosion-re-agent-<platform>`), under the **Corrosion Management** house. The "brought to you by Corrosion Management and Chemistry" line is flavor copy, not canonical.
|
||||||
|
**Leaning (not yet final):** **Formula/Formulae** for deploy manifests (replacing "Blueprints"), repo = **Formulary**, CLI `catalyst deploy --formula rust`.
|
||||||
|
**Reserved well (adopt per-feature as built, don't name vaporware):** the extended terms below — Formulae, Reaction Jobs, Compounds, Lab Notes, Substrate Engine, etc. — are Oracle's proposed expansion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
That rebrand is **way stronger** than "Corrosion Management Panel."
|
||||||
|
|
||||||
|
You now have a real naming system instead of a generic panel name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Corrosion Management
|
||||||
|
├─ Catalyst Control Panel
|
||||||
|
├─ Substrate
|
||||||
|
└─ re-Agent
|
||||||
|
```
|
||||||
|
|
||||||
|
That works because all three names belong to the same metaphor family:
|
||||||
|
|
||||||
|
| Name | Meaning | Product fit |
|
||||||
|
| ------------- | --------------------------------------------------- | ---------------------------- |
|
||||||
|
| **Corrosion** | transformation, decay, reaction, harsh environments | game/server management brand |
|
||||||
|
| **Catalyst** | accelerates reactions without being consumed | operator/admin control panel |
|
||||||
|
| **Substrate** | material/process layer where reaction happens | automation/workflow engine |
|
||||||
|
| **re-Agent** | chemical reagent + remote agent pun | host/node agent |
|
||||||
|
|
||||||
|
**Catalyst Control Panel** is especially good. It sounds polished, memorable, and domain-specific without being cartoonish. It also gives you a clean product phrase:
|
||||||
|
|
||||||
|
> "Manage your servers from Catalyst."
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
> "Catalyst by Corrosion."
|
||||||
|
|
||||||
|
## Tightened brand hierarchy
|
||||||
|
|
||||||
|
```text
|
||||||
|
Corrosion
|
||||||
|
Game server orchestration platform
|
||||||
|
|
||||||
|
Catalyst
|
||||||
|
The web control panel
|
||||||
|
|
||||||
|
Substrate
|
||||||
|
The automation and process engine
|
||||||
|
|
||||||
|
re-Agent
|
||||||
|
The BYOH node agent
|
||||||
|
|
||||||
|
Chemistry
|
||||||
|
Optional umbrella/library/ecosystem name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tagline caution
|
||||||
|
|
||||||
|
The one thing to be careful with is the phrase:
|
||||||
|
|
||||||
|
> "brought to you by Corrosion Management and Chemistry"
|
||||||
|
|
||||||
|
That is clever, but maybe a little wordy for production branding. Use it as flavor copy, not the canonical name.
|
||||||
|
|
||||||
|
Better canonical versions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Catalyst Control Panel
|
||||||
|
by Corrosion
|
||||||
|
|
||||||
|
Catalyst
|
||||||
|
A Corrosion Management product
|
||||||
|
|
||||||
|
Catalyst
|
||||||
|
Powered by Corrosion Chemistry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binary naming
|
||||||
|
|
||||||
|
The binary naming is solid:
|
||||||
|
|
||||||
|
```text
|
||||||
|
corrosion-re-agent-win-amd64.exe
|
||||||
|
corrosion-re-agent-linux-amd64
|
||||||
|
corrosion-re-agent-linux-arm64
|
||||||
|
corrosion-re-agent-darwin-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
Use **linux** instead of **nix** in binary names unless you specifically mean all Unix-like systems — `nix-amd64` can be confused with NixOS / the Nix package manager. For clarity:
|
||||||
|
|
||||||
|
```text
|
||||||
|
corrosion-re-agent-linux-amd64
|
||||||
|
corrosion-re-agent-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Favorite full taxonomy
|
||||||
|
|
||||||
|
```text
|
||||||
|
Corrosion
|
||||||
|
├─ Catalyst Console # UI/control panel
|
||||||
|
├─ Substrate Engine # automation/workflows
|
||||||
|
├─ re-Agent # BYOH host/node agent
|
||||||
|
├─ Formulae # server templates/manifests
|
||||||
|
├─ Reaction Jobs # queued automation runs
|
||||||
|
├─ Compounds # grouped services/stacks
|
||||||
|
└─ Lab Notes # audit/log/event history
|
||||||
|
```
|
||||||
|
|
||||||
|
That gives Corrosion its own identity while still letting **OxideDock** sit underneath as the container orchestration substrate.
|
||||||
|
|
||||||
|
## Clean separation
|
||||||
|
|
||||||
|
```text
|
||||||
|
Catalyst / Corrosion
|
||||||
|
Game-aware:
|
||||||
|
- Dune BattleGroups
|
||||||
|
- Rust servers
|
||||||
|
- wipes
|
||||||
|
- mods
|
||||||
|
- game lifecycle
|
||||||
|
- player/admin-facing concepts
|
||||||
|
|
||||||
|
OxideDock
|
||||||
|
Infra-aware:
|
||||||
|
- Docker
|
||||||
|
- Compose
|
||||||
|
- Swarm
|
||||||
|
- agents
|
||||||
|
- logs
|
||||||
|
- metrics
|
||||||
|
- stack deploys
|
||||||
|
- audit
|
||||||
|
```
|
||||||
|
|
||||||
|
So in practice:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Catalyst asks:
|
||||||
|
"Deploy Dune BattleGroup Alpha."
|
||||||
|
|
||||||
|
Substrate decides:
|
||||||
|
"Run the BattleGroup deployment workflow."
|
||||||
|
|
||||||
|
re-Agent reports:
|
||||||
|
"This BYOH node is ready."
|
||||||
|
|
||||||
|
OxideDock executes:
|
||||||
|
"Render/deploy/update the container stack."
|
||||||
|
```
|
||||||
|
|
||||||
|
That is a **very clean product ecosystem**.
|
||||||
|
|
||||||
|
## One rename suggestion
|
||||||
|
|
||||||
|
Consider **Catalyst Console** over **Catalyst Control Panel** for the polished SaaS/product name. But if you like the old-school "control panel" vibe, **Catalyst Control Panel** absolutely works.
|
||||||
@@ -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." />
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Logo — Corrosion brand lockup.
|
* Logo — Catalyst brand lockup.
|
||||||
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
|
* Composes the CorrosionMark SVG + Oxanium wordmark "Catalyst" + optional tagline.
|
||||||
*
|
*
|
||||||
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
||||||
* parent (or pass `markColor`) to theme it per active game.
|
* parent (or pass `markColor`) to theme it per active game.
|
||||||
*
|
*
|
||||||
* Props mirror Logo.jsx exactly:
|
* Props mirror Logo.jsx exactly:
|
||||||
* size — base px size; drives mark em-size + wordmark scaling
|
* size — base px size; drives mark em-size + wordmark scaling
|
||||||
* wordmark — show the "Corrosion" text (default true)
|
* wordmark — show the "Catalyst" text (default true)
|
||||||
* tagline — false | true (→ "Management Panel") | custom string
|
* tagline — false | true (→ "by Corrosion") | custom string
|
||||||
* glow — accent drop-shadow for marketing / login hero use
|
* glow — accent drop-shadow for marketing / login hero use
|
||||||
* markColor — force a fixed color on the mark (bypasses currentColor theming)
|
* markColor — force a fixed color on the mark (bypasses currentColor theming)
|
||||||
*/
|
*/
|
||||||
@@ -35,7 +35,7 @@ const glowFilter = computed(() =>
|
|||||||
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
|
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
|
||||||
)
|
)
|
||||||
const tagText = computed(() =>
|
const tagText = computed(() =>
|
||||||
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
|
typeof props.tagline === 'string' ? props.tagline : 'by Corrosion'
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ const tagText = computed(() =>
|
|||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}"
|
}"
|
||||||
>Corrosion</span>
|
>Catalyst</span>
|
||||||
<span
|
<span
|
||||||
v-if="tagline"
|
v-if="tagline"
|
||||||
:style="{
|
:style="{
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const agentLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
// One host → its hostname; multiple → fleet count.
|
// One host → its hostname; multiple → fleet count.
|
||||||
const agentName = computed(() =>
|
const agentName = computed(() =>
|
||||||
hostCount.value === 1 ? (realHosts.value[0]?.hostname ?? 'Host agent') : `${hostCount.value} hosts`,
|
hostCount.value === 1 ? (realHosts.value[0]?.hostname ?? 're-Agent') : `${hostCount.value} hosts`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const agentMetaLine = computed(() => {
|
const agentMetaLine = computed(() => {
|
||||||
@@ -231,9 +231,9 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
|||||||
<div v-else class="agent agent--empty">
|
<div v-else class="agent agent--empty">
|
||||||
<div class="agent__row">
|
<div class="agent__row">
|
||||||
<StatusDot tone="offline" />
|
<StatusDot tone="offline" />
|
||||||
<span class="agent__name agent__name--muted">No host agent connected</span>
|
<span class="agent__name agent__name--muted">No re-Agent connected</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="agent__meta">Install the Corrosion host agent from the Server page</div>
|
<div class="agent__meta">Install re-Agent from the Server page</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- User / logout row -->
|
<!-- User / logout row -->
|
||||||
<div class="side__user">
|
<div class="side__user">
|
||||||
@@ -272,7 +272,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
|||||||
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div class="top__crumbs">
|
<div class="top__crumbs">
|
||||||
<span class="crumb">Corrosion</span>
|
<span class="crumb">Catalyst</span>
|
||||||
<span class="crumb__sep">/</span>
|
<span class="crumb__sep">/</span>
|
||||||
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function applyActiveGame(g: ActiveGame, persist: boolean): void {
|
|||||||
/**
|
/**
|
||||||
* Derive the active game from the deployed fleet — the game instances are the
|
* Derive the active game from the deployed fleet — the game instances are the
|
||||||
* source of truth for which game(s) a license runs (game_instances.game, set by
|
* source of truth for which game(s) a license runs (game_instances.game, set by
|
||||||
* the host agent). Exactly one game deployed → skin the shell to it; zero or
|
* re-Agent). Exactly one game deployed → skin the shell to it; zero or
|
||||||
* multiple → 'all' (neutral house skin).
|
* multiple → 'all' (neutral house skin).
|
||||||
*
|
*
|
||||||
* NO-OP when the operator has a manual pick stored (cc-active-game present): an
|
* NO-OP when the operator has a manual pick stored (cc-active-game present): an
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const marketingChildren: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/marketing/HowItWorksView.vue'),
|
component: () => import('@/views/marketing/HowItWorksView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'How It Works — Corrosion',
|
title: 'How It Works — Corrosion',
|
||||||
description: 'Install one host agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
|
description: 'Install one re-Agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,7 +70,7 @@ const marketingChildren: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/marketing/RoadmapView.vue'),
|
component: () => import('@/views/marketing/RoadmapView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Roadmap — Corrosion',
|
title: 'Roadmap — Corrosion',
|
||||||
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game blueprints. Planned: API access, integrations.',
|
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game Formulae. Planned: API access, integrations.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -94,25 +94,25 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/auth/LoginView.vue'),
|
component: () => import('@/views/auth/LoginView.vue'),
|
||||||
meta: { guest: true, title: 'Sign in — Corrosion' },
|
meta: { guest: true, title: 'Sign in to Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: () => import('@/views/auth/RegisterView.vue'),
|
component: () => import('@/views/auth/RegisterView.vue'),
|
||||||
meta: { guest: true, title: 'Create account — Corrosion' },
|
meta: { guest: true, title: 'Create account — Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
name: 'forgot-password',
|
name: 'forgot-password',
|
||||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||||
meta: { guest: true, title: 'Reset password — Corrosion' },
|
meta: { guest: true, title: 'Reset password — Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/setup',
|
path: '/setup',
|
||||||
name: 'setup-wizard',
|
name: 'setup-wizard',
|
||||||
component: () => import('@/views/auth/SetupWizardView.vue'),
|
component: () => import('@/views/auth/SetupWizardView.vue'),
|
||||||
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
|
meta: { requiresAuth: true, title: 'Setup — Catalyst' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin dashboard routes (with sidebar layout)
|
// Admin dashboard routes (with sidebar layout)
|
||||||
@@ -125,260 +125,260 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('@/views/admin/DashboardView.vue'),
|
component: () => import('@/views/admin/DashboardView.vue'),
|
||||||
meta: { title: 'Dashboard — Corrosion' },
|
meta: { title: 'Dashboard · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'server',
|
path: 'server',
|
||||||
name: 'server',
|
name: 'server',
|
||||||
component: () => import('@/views/admin/ServerView.vue'),
|
component: () => import('@/views/admin/ServerView.vue'),
|
||||||
meta: { title: 'Server — Corrosion' },
|
meta: { title: 'Server · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'console',
|
path: 'console',
|
||||||
name: 'console',
|
name: 'console',
|
||||||
component: () => import('@/views/admin/ConsoleView.vue'),
|
component: () => import('@/views/admin/ConsoleView.vue'),
|
||||||
meta: { title: 'Console — Corrosion' },
|
meta: { title: 'Console · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'players',
|
path: 'players',
|
||||||
name: 'players',
|
name: 'players',
|
||||||
component: () => import('@/views/admin/PlayersView.vue'),
|
component: () => import('@/views/admin/PlayersView.vue'),
|
||||||
meta: { title: 'Players — Corrosion' },
|
meta: { title: 'Players · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'plugins',
|
path: 'plugins',
|
||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
component: () => import('@/views/admin/PluginsView.vue'),
|
component: () => import('@/views/admin/PluginsView.vue'),
|
||||||
meta: { title: 'Plugins — Corrosion' },
|
meta: { title: 'Plugins · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'files',
|
path: 'files',
|
||||||
name: 'files',
|
name: 'files',
|
||||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||||
meta: { title: 'Files — Corrosion' },
|
meta: { title: 'Files · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'plugin-configs',
|
path: 'plugin-configs',
|
||||||
name: 'plugin-configs',
|
name: 'plugin-configs',
|
||||||
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
||||||
meta: { title: 'Plugin Configs — Corrosion' },
|
meta: { title: 'Plugin Configs · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'loot-builder',
|
path: 'loot-builder',
|
||||||
name: 'loot-builder',
|
name: 'loot-builder',
|
||||||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||||
meta: { title: 'Loot Builder — Corrosion' },
|
meta: { title: 'Loot Builder · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'teleport-config',
|
path: 'teleport-config',
|
||||||
name: 'teleport-config',
|
name: 'teleport-config',
|
||||||
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||||||
meta: { title: 'Teleport Config — Corrosion' },
|
meta: { title: 'Teleport Config · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'gather-manager',
|
path: 'gather-manager',
|
||||||
name: 'gather-manager',
|
name: 'gather-manager',
|
||||||
component: () => import('@/views/admin/GatherManagerView.vue'),
|
component: () => import('@/views/admin/GatherManagerView.vue'),
|
||||||
meta: { title: 'Gather Manager — Corrosion' },
|
meta: { title: 'Gather Manager · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'autodoors',
|
path: 'autodoors',
|
||||||
name: 'autodoors',
|
name: 'autodoors',
|
||||||
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
||||||
meta: { title: 'Auto Doors — Corrosion' },
|
meta: { title: 'Auto Doors · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'kits',
|
path: 'kits',
|
||||||
name: 'kits-config',
|
name: 'kits-config',
|
||||||
component: () => import('@/views/admin/KitsView.vue'),
|
component: () => import('@/views/admin/KitsView.vue'),
|
||||||
meta: { title: 'Kits — Corrosion' },
|
meta: { title: 'Kits · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'furnace-splitter',
|
path: 'furnace-splitter',
|
||||||
name: 'furnace-splitter',
|
name: 'furnace-splitter',
|
||||||
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
||||||
meta: { title: 'Furnace Splitter — Corrosion' },
|
meta: { title: 'Furnace Splitter · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'better-chat',
|
path: 'better-chat',
|
||||||
name: 'better-chat',
|
name: 'better-chat',
|
||||||
component: () => import('@/views/admin/BetterChatView.vue'),
|
component: () => import('@/views/admin/BetterChatView.vue'),
|
||||||
meta: { title: 'Better Chat — Corrosion' },
|
meta: { title: 'Better Chat · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'timed-execute',
|
path: 'timed-execute',
|
||||||
name: 'timed-execute',
|
name: 'timed-execute',
|
||||||
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
||||||
meta: { title: 'Timed Execute — Corrosion' },
|
meta: { title: 'Timed Execute · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'raidable-bases',
|
path: 'raidable-bases',
|
||||||
name: 'raidable-bases',
|
name: 'raidable-bases',
|
||||||
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
||||||
meta: { title: 'Raidable Bases — Corrosion' },
|
meta: { title: 'Raidable Bases · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes',
|
path: 'wipes',
|
||||||
name: 'wipes',
|
name: 'wipes',
|
||||||
component: () => import('@/views/admin/WipesView.vue'),
|
component: () => import('@/views/admin/WipesView.vue'),
|
||||||
meta: { title: 'Wipes — Corrosion' },
|
meta: { title: 'Wipes · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/profiles',
|
path: 'wipes/profiles',
|
||||||
name: 'wipe-profiles',
|
name: 'wipe-profiles',
|
||||||
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
||||||
meta: { title: 'Wipe Profiles — Corrosion' },
|
meta: { title: 'Wipe Profiles · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/calendar',
|
path: 'wipes/calendar',
|
||||||
name: 'wipe-calendar',
|
name: 'wipe-calendar',
|
||||||
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
||||||
meta: { title: 'Wipe Calendar — Corrosion' },
|
meta: { title: 'Wipe Calendar · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/history',
|
path: 'wipes/history',
|
||||||
name: 'wipe-history',
|
name: 'wipe-history',
|
||||||
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
||||||
meta: { title: 'Wipe History — Corrosion' },
|
meta: { title: 'Wipe History · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/analytics',
|
path: 'wipes/analytics',
|
||||||
name: 'wipe-analytics',
|
name: 'wipe-analytics',
|
||||||
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
||||||
meta: { title: 'Wipe Analytics — Corrosion' },
|
meta: { title: 'Wipe Analytics · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'maps',
|
path: 'maps',
|
||||||
name: 'maps',
|
name: 'maps',
|
||||||
component: () => import('@/views/admin/MapsView.vue'),
|
component: () => import('@/views/admin/MapsView.vue'),
|
||||||
meta: { title: 'Maps — Corrosion' },
|
meta: { title: 'Maps · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'maps/analytics',
|
path: 'maps/analytics',
|
||||||
name: 'map-analytics',
|
name: 'map-analytics',
|
||||||
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
||||||
meta: { title: 'Map Analytics — Corrosion' },
|
meta: { title: 'Map Analytics · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'chat',
|
path: 'chat',
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
component: () => import('@/views/admin/ChatLogView.vue'),
|
component: () => import('@/views/admin/ChatLogView.vue'),
|
||||||
meta: { title: 'Chat Log — Corrosion' },
|
meta: { title: 'Chat Log · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'analytics',
|
path: 'analytics',
|
||||||
name: 'analytics',
|
name: 'analytics',
|
||||||
component: () => import('@/views/admin/AnalyticsView.vue'),
|
component: () => import('@/views/admin/AnalyticsView.vue'),
|
||||||
meta: { title: 'Analytics — Corrosion' },
|
meta: { title: 'Analytics · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'retention',
|
path: 'retention',
|
||||||
name: 'retention',
|
name: 'retention',
|
||||||
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
||||||
meta: { title: 'Player Retention — Corrosion' },
|
meta: { title: 'Player Retention · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
name: 'notifications',
|
name: 'notifications',
|
||||||
component: () => import('@/views/admin/NotificationsView.vue'),
|
component: () => import('@/views/admin/NotificationsView.vue'),
|
||||||
meta: { title: 'Notifications — Corrosion' },
|
meta: { title: 'Notifications · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'team',
|
path: 'team',
|
||||||
name: 'team',
|
name: 'team',
|
||||||
component: () => import('@/views/admin/TeamView.vue'),
|
component: () => import('@/views/admin/TeamView.vue'),
|
||||||
meta: { title: 'Team — Corrosion' },
|
meta: { title: 'Team · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/config',
|
path: 'store/config',
|
||||||
name: 'store-config',
|
name: 'store-config',
|
||||||
component: () => import('@/views/admin/StoreConfigView.vue'),
|
component: () => import('@/views/admin/StoreConfigView.vue'),
|
||||||
meta: { title: 'Store Config — Corrosion' },
|
meta: { title: 'Store Config · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/items',
|
path: 'store/items',
|
||||||
name: 'store-items',
|
name: 'store-items',
|
||||||
component: () => import('@/views/admin/StoreItemsView.vue'),
|
component: () => import('@/views/admin/StoreItemsView.vue'),
|
||||||
meta: { title: 'Store Items — Corrosion' },
|
meta: { title: 'Store Items · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/revenue',
|
path: 'store/revenue',
|
||||||
name: 'store-revenue',
|
name: 'store-revenue',
|
||||||
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
||||||
meta: { title: 'Store Revenue — Corrosion' },
|
meta: { title: 'Store Revenue · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'modules',
|
path: 'modules',
|
||||||
name: 'modules',
|
name: 'modules',
|
||||||
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
||||||
meta: { title: 'Modules — Corrosion' },
|
meta: { title: 'Modules · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('@/views/admin/SettingsView.vue'),
|
component: () => import('@/views/admin/SettingsView.vue'),
|
||||||
meta: { title: 'Settings — Corrosion' },
|
meta: { title: 'Settings · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'schedules',
|
path: 'schedules',
|
||||||
name: 'schedules',
|
name: 'schedules',
|
||||||
component: () => import('@/views/admin/SchedulesView.vue'),
|
component: () => import('@/views/admin/SchedulesView.vue'),
|
||||||
meta: { title: 'Schedules — Corrosion' },
|
meta: { title: 'Schedules · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'migration',
|
path: 'migration',
|
||||||
name: 'migration',
|
name: 'migration',
|
||||||
component: () => import('@/views/admin/MigrationView.vue'),
|
component: () => import('@/views/admin/MigrationView.vue'),
|
||||||
meta: { title: 'Migration — Corrosion' },
|
meta: { title: 'Migration · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
name: 'changelog',
|
name: 'changelog',
|
||||||
component: () => import('@/views/admin/ChangelogView.vue'),
|
component: () => import('@/views/admin/ChangelogView.vue'),
|
||||||
meta: { title: 'Changelog — Corrosion' },
|
meta: { title: 'Changelog · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'alerts',
|
path: 'alerts',
|
||||||
name: 'alerts',
|
name: 'alerts',
|
||||||
component: () => import('@/views/admin/AlertsView.vue'),
|
component: () => import('@/views/admin/AlertsView.vue'),
|
||||||
meta: { title: 'Alerts — Corrosion' },
|
meta: { title: 'Alerts · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'fleet',
|
path: 'fleet',
|
||||||
name: 'fleet',
|
name: 'fleet',
|
||||||
component: () => import('@/views/admin/FleetView.vue'),
|
component: () => import('@/views/admin/FleetView.vue'),
|
||||||
meta: { title: 'Fleet — Corrosion', requiresAuth: true },
|
meta: { title: 'Fleet · Catalyst', requiresAuth: true },
|
||||||
},
|
},
|
||||||
// Platform Admin views (super-admin only)
|
// Platform Admin views (super-admin only)
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
name: 'platform-admin',
|
name: 'platform-admin',
|
||||||
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/licenses',
|
path: 'admin/licenses',
|
||||||
name: 'platform-licenses',
|
name: 'platform-licenses',
|
||||||
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin: Licenses · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/subscriptions',
|
path: 'admin/subscriptions',
|
||||||
name: 'platform-subscriptions',
|
name: 'platform-subscriptions',
|
||||||
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin: Subscriptions · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
name: 'platform-users',
|
name: 'platform-users',
|
||||||
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin: Users · Catalyst' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/servers',
|
path: 'admin/servers',
|
||||||
name: 'platform-servers',
|
name: 'platform-servers',
|
||||||
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
||||||
meta: { superAdmin: true, title: 'Admin: Servers — Corrosion' },
|
meta: { superAdmin: true, title: 'Admin: Servers · Catalyst' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -413,7 +413,7 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
path: '/status',
|
path: '/status',
|
||||||
name: 'status',
|
name: 'status',
|
||||||
component: () => import('@/views/public/StatusPageView.vue'),
|
component: () => import('@/views/public/StatusPageView.vue'),
|
||||||
meta: { title: 'Status — Corrosion' },
|
meta: { title: 'Status · Catalyst' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Catch-all
|
// Catch-all
|
||||||
@@ -488,14 +488,14 @@ function setOrClearMeta(selector: string, attr: string, value: string): void {
|
|||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
// Title
|
// Title
|
||||||
document.title = to.meta.title ?? 'Corrosion Management'
|
document.title = to.meta.title ?? 'Catalyst Console'
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
const desc = to.meta.description ?? ''
|
const desc = to.meta.description ?? ''
|
||||||
setOrClearMeta('meta[name="description"]', 'content', desc)
|
setOrClearMeta('meta[name="description"]', 'content', desc)
|
||||||
|
|
||||||
// OG title
|
// OG title
|
||||||
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Corrosion Management')
|
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Catalyst Console')
|
||||||
|
|
||||||
// OG description
|
// OG description
|
||||||
setOrClearMeta('meta[property="og:description"]', 'content', desc)
|
setOrClearMeta('meta[property="og:description"]', 'content', desc)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
async function list(path: string): Promise<void> {
|
async function list(path: string): Promise<void> {
|
||||||
const id = currentId()
|
const id = currentId()
|
||||||
if (!id) {
|
if (!id) {
|
||||||
error.value = 'No instance — connect the host agent'
|
error.value = 'No instance — connect re-Agent'
|
||||||
entries.value = []
|
entries.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function handleWebSocketMessage(message: WebSocketMessage) {
|
|||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addLine('Corrosion console initialized.', 'system')
|
addLine('Catalyst Console initialized.', 'system')
|
||||||
addLine('Type a command and press Enter to send it to the server.', 'system')
|
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||||
if (server.connection?.connection_status !== 'connected') {
|
if (server.connection?.connection_status !== 'connected') {
|
||||||
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ function navServer() { router.push('/server') }
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No server connected"
|
title="No server connected"
|
||||||
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
|
description="Install re-Agent on your host machine to begin managing your server from Catalyst."
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
<Button icon="server" @click="navServer">Set up server</Button>
|
<Button icon="server" @click="navServer">Set up server</Button>
|
||||||
@@ -404,7 +404,7 @@ function navServer() { router.push('/server') }
|
|||||||
<div class="dash__col dash__col--side">
|
<div class="dash__col dash__col--side">
|
||||||
|
|
||||||
<!-- Resources — real stats from agent; null = '—' -->
|
<!-- Resources — real stats from agent; null = '—' -->
|
||||||
<Panel title="Resources" subtitle="Host agent telemetry">
|
<Panel title="Resources" subtitle="re-Agent telemetry">
|
||||||
<div class="solo-meters">
|
<div class="solo-meters">
|
||||||
<ResourceMeter
|
<ResourceMeter
|
||||||
label="CPU"
|
label="CPU"
|
||||||
@@ -418,7 +418,7 @@ function navServer() { router.push('/server') }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
||||||
Resource metrics arrive via the host agent heartbeat.
|
Resource metrics arrive via re-Agent heartbeat.
|
||||||
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
||||||
Agent setup
|
Agent setup
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ async function confirmDelete(path: string) {
|
|||||||
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
|
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No host agent connected"
|
title="No re-Agent connected"
|
||||||
description="Install the host agent from the Server page to manage files on your game server."
|
description="Install re-Agent from the Server page to manage files on your game server."
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
|
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function relativeHeartbeat(iso: string | null): string {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No hosts connected yet"
|
title="No hosts connected yet"
|
||||||
description="Install the Corrosion host agent on your server machine to see it here."
|
description="Install re-Agent on your server machine to see it here."
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>
|
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const tabItems = [
|
|||||||
function sourceLabel(source: string): string {
|
function sourceLabel(source: string): string {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'umod': return 'uMod'
|
case 'umod': return 'uMod'
|
||||||
case 'corrosion_module': return 'Corrosion'
|
case 'corrosion_module': return 'Catalyst'
|
||||||
case 'manual': return 'Manual'
|
case 'manual': return 'Manual'
|
||||||
default: return source
|
default: return source
|
||||||
}
|
}
|
||||||
@@ -485,7 +485,7 @@ onMounted(() => {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Alert tone="info">
|
<Alert tone="info">
|
||||||
The plugin will be registered in your plugin list immediately. Your host agent must be connected
|
The plugin will be registered in your plugin list immediately. Your re-Agent must be connected
|
||||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ async function saveConfig() {
|
|||||||
|
|
||||||
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||||
if (!currentInstance.value) {
|
if (!currentInstance.value) {
|
||||||
toast.error('No game instance to control — connect the host agent first')
|
toast.error('No game instance to control — connect re-Agent first')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
actionLoading.value = action
|
actionLoading.value = action
|
||||||
@@ -532,7 +532,7 @@ onMounted(async () => {
|
|||||||
v-if="!currentInstance"
|
v-if="!currentInstance"
|
||||||
icon="server"
|
icon="server"
|
||||||
title="No game instance connected"
|
title="No game instance connected"
|
||||||
:description="'Install the host agent and add a ' + profile.label + ' instance to its config to manage it here.'"
|
:description="'Install re-Agent and add a ' + profile.label + ' instance to its config to manage it here.'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -611,7 +611,7 @@ onMounted(async () => {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Host agent -->
|
<!-- Host agent -->
|
||||||
<Panel title="Host agent" subtitle="Bare-metal server management binary">
|
<Panel title="re-Agent" subtitle="Bare-metal server management binary">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
||||||
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
||||||
@@ -640,7 +640,7 @@ onMounted(async () => {
|
|||||||
<!-- Download -->
|
<!-- Download -->
|
||||||
<div class="sv__section-head">
|
<div class="sv__section-head">
|
||||||
<Icon name="download" :size="14" />
|
<Icon name="download" :size="14" />
|
||||||
<span>Download host agent</span>
|
<span>Download re-Agent</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sv__downloads sv__mb">
|
<div class="sv__downloads sv__mb">
|
||||||
<a
|
<a
|
||||||
@@ -683,7 +683,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Linux commands -->
|
<!-- Linux commands -->
|
||||||
<div v-if="setupTab === 'linux'" class="sv__codeblock">
|
<div v-if="setupTab === 'linux'" class="sv__codeblock">
|
||||||
<p class="sv__cmt"># Download the agent</p>
|
<p class="sv__cmt"># Download re-Agent</p>
|
||||||
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||||
<p>chmod +x corrosion-host-agent-linux-amd64</p>
|
<p>chmod +x corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="sv__cmt sv__mt"># Write /etc/corrosion/agent.toml (see config block below), then run:</p>
|
<p class="sv__cmt sv__mt"># Write /etc/corrosion/agent.toml (see config block below), then run:</p>
|
||||||
@@ -694,7 +694,7 @@ onMounted(async () => {
|
|||||||
<!-- Windows commands -->
|
<!-- Windows commands -->
|
||||||
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
||||||
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
||||||
<p class="sv__cmt"># Download the agent</p>
|
<p class="sv__cmt"># Download re-Agent</p>
|
||||||
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
|
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
|
||||||
<p class="sv__cmt sv__mt"># Write C:\ProgramData\Corrosion\agent.toml (see config block below), then run:</p>
|
<p class="sv__cmt sv__mt"># Write C:\ProgramData\Corrosion\agent.toml (see config block below), then run:</p>
|
||||||
<p>New-Item -ItemType Directory -Force -Path <span class="sv__accent">"C:\ProgramData\Corrosion"</span></p>
|
<p>New-Item -ItemType Directory -Force -Path <span class="sv__accent">"C:\ProgramData\Corrosion"</span></p>
|
||||||
@@ -726,7 +726,7 @@ onMounted(async () => {
|
|||||||
<pre class="sv__pre">{{ agentTomlConfig }}</pre>
|
<pre class="sv__pre">{{ agentTomlConfig }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<Alert v-if="!agentCreds" tone="warn" class="sv__mt">
|
<Alert v-if="!agentCreds" tone="warn" class="sv__mt">
|
||||||
Could not load credentials from server. Copy this config and replace the placeholders with values from your Corrosion dashboard settings.
|
Could not load credentials from server. Copy this config and replace the placeholders with values from your Catalyst dashboard settings.
|
||||||
</Alert>
|
</Alert>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -858,7 +858,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="box"
|
icon="box"
|
||||||
title="Docker-managed deployment"
|
title="Docker-managed deployment"
|
||||||
:description="profile.label + ' servers are managed via Docker Compose. Connect the host agent on your Docker host to enable lifecycle management.'"
|
:description="profile.label + ' servers are managed via Docker Compose. Connect re-Agent on your Docker host to enable lifecycle management.'"
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
<Badge tone="info">Docker · Compose</Badge>
|
<Badge tone="info">Docker · Compose</Badge>
|
||||||
@@ -929,7 +929,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="layers"
|
icon="layers"
|
||||||
:title="profile.label + ' mod management'"
|
:title="profile.label + ' mod management'"
|
||||||
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Corrosion install step needed.'"
|
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Catalyst install step needed.'"
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -973,7 +973,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="network"
|
icon="network"
|
||||||
title="Cluster management coming soon"
|
title="Cluster management coming soon"
|
||||||
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires the host agent.'"
|
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires re-Agent.'"
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -986,7 +986,7 @@ onMounted(async () => {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="map"
|
icon="map"
|
||||||
title="Sietch management requires a connected Dune host"
|
title="Sietch management requires a connected Dune host"
|
||||||
description="Connect the host agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
|
description="Connect re-Agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function handleBackToLogin() {
|
|||||||
<!-- Branding -->
|
<!-- Branding -->
|
||||||
<div class="auth-brand">
|
<div class="auth-brand">
|
||||||
<div class="auth-brand__mark">
|
<div class="auth-brand__mark">
|
||||||
<Logo :size="40" :glow="true" tagline="Game Server Operations" />
|
<Logo :size="40" :glow="true" tagline="by Corrosion" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function syncPorts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectionTypes = [
|
const connectionTypes = [
|
||||||
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Corrosion host agent' },
|
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via re-Agent' },
|
||||||
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
||||||
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
||||||
]
|
]
|
||||||
@@ -183,7 +183,7 @@ async function completeSetup() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Corrosion host agent install -->
|
<!-- Step 2: re-Agent install -->
|
||||||
<div v-if="step === 2" class="setup-card">
|
<div v-if="step === 2" class="setup-card">
|
||||||
<div class="setup-card__head setup-card__head--center">
|
<div class="setup-card__head setup-card__head--center">
|
||||||
<div class="setup-icon">
|
<div class="setup-icon">
|
||||||
@@ -191,12 +191,12 @@ async function completeSetup() {
|
|||||||
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
|
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="setup-card__title">Install the Corrosion host agent</h1>
|
<h1 class="setup-card__title">Install re-Agent</h1>
|
||||||
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
<p class="setup-card__sub">re-Agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-code">
|
<div class="setup-code">
|
||||||
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
|
<p class="setup-code__comment"># Download re-Agent (Linux)</p>
|
||||||
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
|
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
||||||
@@ -206,7 +206,7 @@ async function completeSetup() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setup-hint">
|
<p class="setup-hint">
|
||||||
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
|
On Windows, download re-Agent from the Server page after setup. re-Agent connects outbound and auto-registers with Catalyst.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="setup-actions">
|
<div class="setup-actions">
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<h2 class="title">Real access to a real platform.</h2>
|
<h2 class="title">Real access to a real platform.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Early access is not a waitlist gimmick. It is how we manage onboarding while the
|
Early access is not a waitlist gimmick. It is how we manage onboarding while the
|
||||||
platform stabilizes. You get the full Corrosion control plane — one tier at a time
|
platform stabilizes. You get the full Catalyst Console control plane — one tier at a time
|
||||||
as capacity opens.
|
as capacity opens.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +114,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
<b>Full control plane</b>
|
<b>Full control plane</b>
|
||||||
<p>Agent, panel, wipes, console, plugins, schedules — all of it. Not a trimmed preview.</p>
|
<p>re-Agent, Catalyst Console, wipes, plugins, schedules — all of it. Not a trimmed preview.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
@@ -248,17 +248,17 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<div class="sec__head reveal">
|
||||||
<span class="eyebrow">How it works</span>
|
<span class="eyebrow">How it works</span>
|
||||||
<h2 class="title">Install the agent. Never SSH again.</h2>
|
<h2 class="title">Install re-Agent. Never SSH again.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="steps reveal">
|
<div class="steps reveal">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">1</div>
|
<div class="step__n">1</div>
|
||||||
<b>Install the host agent</b>
|
<b>Install re-Agent</b>
|
||||||
<p>Download the Go binary from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
|
<p>Download re-Agent from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">2</div>
|
<div class="step__n">2</div>
|
||||||
<b>Agent connects to Corrosion</b>
|
<b>re-Agent connects to Corrosion</b>
|
||||||
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
|
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
|
|||||||
@@ -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?',
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
</div>
|
</div>
|
||||||
<span class="eyebrow">How it works</span>
|
<span class="eyebrow">How it works</span>
|
||||||
<h1 style="font-size:var(--text-5xl)">
|
<h1 style="font-size:var(--text-5xl)">
|
||||||
One agent.
|
One re-Agent.
|
||||||
<span class="accent">Every game. No SSH.</span>
|
<span class="accent">Every game. No SSH.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero__sub">
|
<p class="hero__sub">
|
||||||
Install the host agent once on your Windows or Linux machine. Corrosion connects
|
Install re-Agent once on your Windows or Linux machine. Corrosion connects
|
||||||
securely, outbound-only. You manage every game instance from the browser.
|
securely, outbound-only. You manage every game instance from Catalyst Console.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -55,20 +55,20 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<section class="sec" id="model">
|
<section class="sec" id="model">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<div class="sec__head reveal">
|
||||||
<span class="eyebrow">The agent model</span>
|
<span class="eyebrow">The re-Agent model</span>
|
||||||
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
|
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Corrosion is not a hosting provider. You supply the hardware or the VPS. The host
|
Corrosion is not a hosting provider. You supply the hardware or the VPS. re-Agent
|
||||||
agent runs on that machine and bridges your game instances to Corrosion's control
|
runs on that machine and bridges your game instances to Corrosion's control
|
||||||
plane — securely, without opening inbound firewall ports.
|
plane — securely, without opening inbound firewall ports.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="steps reveal">
|
<div class="steps reveal">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">1</div>
|
<div class="step__n">1</div>
|
||||||
<b>Install the host agent</b>
|
<b>Install re-Agent</b>
|
||||||
<p>
|
<p>
|
||||||
Download the Corrosion agent binary from your dashboard. Run it on any Windows
|
Download the re-Agent binary from your dashboard. Run it on any Windows
|
||||||
or Linux host. One agent per machine — it manages every game instance you assign
|
or Linux host. One agent per machine — it manages every game instance you assign
|
||||||
to it.
|
to it.
|
||||||
</p>
|
</p>
|
||||||
@@ -77,7 +77,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="step__n">2</div>
|
<div class="step__n">2</div>
|
||||||
<b>It connects to Corrosion</b>
|
<b>It connects to Corrosion</b>
|
||||||
<p>
|
<p>
|
||||||
The agent makes a single outbound NATS connection to Corrosion's cloud. No
|
re-Agent makes a single outbound NATS connection to Corrosion's cloud. No
|
||||||
inbound ports. No open panels. No SSH required after initial setup.
|
inbound ports. No open panels. No SSH required after initial setup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +86,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<b>Deploy and manage from the browser</b>
|
<b>Deploy and manage from the browser</b>
|
||||||
<p>
|
<p>
|
||||||
Create game instances, run wipes, manage plugins, schedule maintenance, and
|
Create game instances, run wipes, manage plugins, schedule maintenance, and
|
||||||
monitor players — all from the Corrosion panel at panel.corrosionmgmt.com.
|
monitor players — all from Catalyst Console at panel.corrosionmgmt.com.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,8 +106,8 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<span class="eyebrow">Multi-game host runtime</span>
|
<span class="eyebrow">Multi-game host runtime</span>
|
||||||
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
|
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
The host agent is not a per-game process. It is a general-purpose ops runtime. One
|
re-Agent is not a per-game process. It is a general-purpose ops runtime. One
|
||||||
agent on a single machine can supervise multiple game server processes across
|
re-Agent on a single machine can supervise multiple game server processes across
|
||||||
different games — each with its own configuration, lifecycle, and wipe schedule.
|
different games — each with its own configuration, lifecycle, and wipe schedule.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +220,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<span class="eyebrow">Connectivity model</span>
|
<span class="eyebrow">Connectivity model</span>
|
||||||
<h2 class="title">Outbound-only. No exposed panel.</h2>
|
<h2 class="title">Outbound-only. No exposed panel.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
The host agent establishes one secure NATS connection to Corrosion's cloud. All
|
re-Agent establishes one secure NATS connection to Corrosion's cloud. All
|
||||||
commands flow through that channel. Your machine never needs to accept inbound
|
commands flow through that channel. Your machine never needs to accept inbound
|
||||||
connections from the internet.
|
connections from the internet.
|
||||||
</p>
|
</p>
|
||||||
@@ -234,8 +234,8 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
</div>
|
</div>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
<b>Corrosion agent</b>
|
<b>re-Agent</b>
|
||||||
<p>A single Go binary. Runs as a service. Manages game processes, files, and updates.</p>
|
<p>A single binary. Runs as a service. Manages game processes, files, and updates.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
@@ -250,12 +250,12 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
|
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
|
||||||
<b>Your browser</b>
|
<b>Your browser</b>
|
||||||
<p>The panel at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
|
<p>Catalyst Console at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="techrow reveal">
|
<div class="techrow reveal">
|
||||||
<span>Go host agent</span>
|
<span>re-Agent</span>
|
||||||
<span>NATS JetStream</span>
|
<span>NATS JetStream</span>
|
||||||
<span>NestJS API</span>
|
<span>NestJS API</span>
|
||||||
<span>PostgreSQL</span>
|
<span>PostgreSQL</span>
|
||||||
@@ -289,7 +289,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div>
|
<div>
|
||||||
<b>Enough CPU and RAM for your game</b>
|
<b>Enough CPU and RAM for your game</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
Corrosion's agent is lightweight. Your game server determines the actual
|
re-Agent is lightweight. Your game server determines the actual
|
||||||
hardware requirement.
|
hardware requirement.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +299,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div>
|
<div>
|
||||||
<b>Outbound internet access</b>
|
<b>Outbound internet access</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
The agent connects out; your game server's player ports stay open as they
|
re-Agent connects out; your game server's player ports stay open as they
|
||||||
always have been.
|
always have been.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,7 +310,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div class="feat">
|
<div class="feat">
|
||||||
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
||||||
<div>
|
<div>
|
||||||
<b>Agent binary (Windows or Linux)</b>
|
<b>re-Agent binary (Windows or Linux)</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
Downloaded from your dashboard. No manual build. No dependency management.
|
Downloaded from your dashboard. No manual build. No dependency management.
|
||||||
</p>
|
</p>
|
||||||
@@ -321,14 +321,14 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<div>
|
<div>
|
||||||
<b>Your license key</b>
|
<b>Your license key</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
Issued when you register. The agent uses it to authenticate to the cloud.
|
Issued when you register. re-Agent uses it to authenticate to the cloud.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feat">
|
<div class="feat">
|
||||||
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
|
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
|
||||||
<div>
|
<div>
|
||||||
<b>The panel</b>
|
<b>Catalyst Console</b>
|
||||||
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
Everything else — console, wipes, schedules, players — lives at
|
Everything else — console, wipes, schedules, players — lives at
|
||||||
panel.corrosionmgmt.com.
|
panel.corrosionmgmt.com.
|
||||||
@@ -344,7 +344,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<section class="finalcta">
|
<section class="finalcta">
|
||||||
<div class="finalcta__atmo" />
|
<div class="finalcta__atmo" />
|
||||||
<div class="wrap finalcta__in reveal">
|
<div class="wrap finalcta__in reveal">
|
||||||
<h2>Install the agent.<br>Never SSH again.</h2>
|
<h2>Install re-Agent.<br>Never SSH again.</h2>
|
||||||
<div class="cta-row">
|
<div class="cta-row">
|
||||||
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
Join early access
|
Join early access
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
|
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
|
||||||
<p class="hero__sub">
|
<p class="hero__sub">
|
||||||
Corrosion is a management panel for self-hosted survival game servers. Deploy servers, automate
|
Corrosion is a management platform for self-hosted survival game servers. Deploy servers, automate
|
||||||
wipes, manage plugins and mods, schedule maintenance, monitor players, and orchestrate
|
wipes, manage plugins and mods, schedule maintenance, monitor players, and orchestrate
|
||||||
multi-server worlds — from one command center.
|
multi-server worlds — all from Catalyst Console.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero__cta">
|
<div class="hero__cta">
|
||||||
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
@@ -144,7 +144,7 @@ const mockActiveGame = activeGame
|
|||||||
<aside class="mock__side">
|
<aside class="mock__side">
|
||||||
<div class="mock__brand">
|
<div class="mock__brand">
|
||||||
<span class="mark"><CorrosionMark :size="18" /></span>
|
<span class="mark"><CorrosionMark :size="18" /></span>
|
||||||
<b>Corrosion</b>
|
<b>Catalyst</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="mock__gs">
|
<div class="mock__gs">
|
||||||
<span :class="{ on: mockActiveGame === 'rust' }">
|
<span :class="{ on: mockActiveGame === 'rust' }">
|
||||||
@@ -177,7 +177,7 @@ const mockActiveGame = activeGame
|
|||||||
<div class="v">234</div>
|
<div class="v">234</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mock__kpi">
|
<div class="mock__kpi">
|
||||||
<div class="l">Agent nodes</div>
|
<div class="l">re-Agent nodes</div>
|
||||||
<div class="v">2<small>/2</small></div>
|
<div class="v">2<small>/2</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +219,7 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<div class="wrap" style="text-align:center">
|
<div class="wrap" style="text-align:center">
|
||||||
<div class="hero__foot">
|
<div class="hero__foot">
|
||||||
One host agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
|
One re-Agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
|
||||||
Windows & Linux hosts
|
Windows & Linux hosts
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +269,7 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<p class="closing reveal">
|
<p class="closing reveal">
|
||||||
Your community sees the server. You deal with the chaos.<br>
|
Your community sees the server. You deal with the chaos.<br>
|
||||||
<span class="accent">Corrosion gives you the control plane.</span>
|
<span class="accent">Catalyst Console gives you the control plane.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -279,16 +279,16 @@ const mockActiveGame = activeGame
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<div class="sec__head reveal">
|
||||||
<span class="eyebrow">The shift</span>
|
<span class="eyebrow">The shift</span>
|
||||||
<h2 class="title">Drop in the agent.<br>Take control from the panel.</h2>
|
<h2 class="title">Drop in re-Agent.<br>Take control from Catalyst Console.</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
One lightweight host agent runs on your machine and manages every game instance you assign
|
One lightweight re-Agent runs on your machine and manages every game instance you assign
|
||||||
to it — an outbound-only ops runtime, not an exposed panel.
|
to it — an outbound-only ops runtime, not an exposed panel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="steps reveal">
|
<div class="steps reveal">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step__n">1</div>
|
<div class="step__n">1</div>
|
||||||
<b>Install the Corrosion Agent</b>
|
<b>Install re-Agent</b>
|
||||||
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
|
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
@@ -310,7 +310,7 @@ const mockActiveGame = activeGame
|
|||||||
</div>
|
</div>
|
||||||
<p class="closing reveal" style="font-size:var(--text-lg)">
|
<p class="closing reveal" style="font-size:var(--text-lg)">
|
||||||
You provide the machine.
|
You provide the machine.
|
||||||
<span class="accent">Corrosion provides the control plane.</span>
|
<span class="accent">Catalyst Console provides the control plane.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -320,10 +320,9 @@ const mockActiveGame = activeGame
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="sec__head reveal">
|
<div class="sec__head reveal">
|
||||||
<span class="eyebrow">Supported games</span>
|
<span class="eyebrow">Supported games</span>
|
||||||
<h2 class="title">Game-aware blueprints,<br>not generic templates</h2>
|
<h2 class="title">Game-aware Formulae,<br>not generic configs</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Every game has a different operational reality. Corrosion models each one as an operations
|
Every game has a different operational reality. Corrosion models each one as a Formula — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
||||||
blueprint — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
|
||||||
worlds.
|
worlds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -527,7 +526,7 @@ const mockActiveGame = activeGame
|
|||||||
<span class="eyebrow">Built like infrastructure</span>
|
<span class="eyebrow">Built like infrastructure</span>
|
||||||
<h2 class="title">Not a skin over SSH</h2>
|
<h2 class="title">Not a skin over SSH</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
A hosted control plane plus a host agent — with tenant isolation, command namespacing,
|
A hosted control plane plus re-Agent — with tenant isolation, command namespacing,
|
||||||
health reporting, and outbound-only connectivity.
|
health reporting, and outbound-only connectivity.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,7 +534,7 @@ const mockActiveGame = activeGame
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
<b>Agent-based control</b>
|
<b>Agent-based control</b>
|
||||||
<p>Your host connects to Corrosion. No exposed management panel required.</p>
|
<p>re-Agent connects to Corrosion. No exposed management panel required.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
@@ -550,7 +549,7 @@ const mockActiveGame = activeGame
|
|||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
<b>Event-driven</b>
|
<b>Event-driven</b>
|
||||||
<p>NATS-powered messaging keeps agents and panel in sync.</p>
|
<p>NATS-powered messaging keeps re-Agent and Catalyst Console in sync.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="icard">
|
<div class="icard">
|
||||||
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
||||||
@@ -562,7 +561,7 @@ const mockActiveGame = activeGame
|
|||||||
<span>NestJS</span>
|
<span>NestJS</span>
|
||||||
<span>NATS JetStream</span>
|
<span>NATS JetStream</span>
|
||||||
<span>PostgreSQL</span>
|
<span>PostgreSQL</span>
|
||||||
<span>Go host agent</span>
|
<span>re-Agent</span>
|
||||||
<span>Outbound-only</span>
|
<span>Outbound-only</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ const plans: Plan[] = [
|
|||||||
|
|
||||||
<p class="closing reveal" style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500">
|
<p class="closing reveal" style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500">
|
||||||
Direct server administration, firewall configuration, mod installation, and wipe-day
|
Direct server administration, firewall configuration, mod installation, and wipe-day
|
||||||
hand-holding are not included in any plan. Corrosion gives you the panel and the tools.
|
hand-holding are not included in any plan. Corrosion gives you Catalyst Console and the tools.
|
||||||
You run the operation.
|
You run the operation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ const groups: RoadmapGroup[] = [
|
|||||||
status: 'shipped',
|
status: 'shipped',
|
||||||
label: 'Phase 1 — Foundation',
|
label: 'Phase 1 — Foundation',
|
||||||
description:
|
description:
|
||||||
'The core control plane is live. Game server operators can install the agent, connect their server, and manage it entirely from the panel.',
|
'The core control plane is live. Game server operators can install re-Agent, connect their server, and manage it entirely from Catalyst Console.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
|
{ text: 're-Agent (Windows + Linux)', note: 'Outbound NATS, zero inbound ports' },
|
||||||
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
|
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
|
||||||
{ text: 'Auto-wiper with rollback', note: 'Map, BP, and full wipes as verified sequences' },
|
{ text: 'Auto-wiper with rollback', note: 'Map, BP, and full wipes as verified sequences' },
|
||||||
{ text: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
|
{ text: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
|
||||||
{ text: 'Real-time console', note: 'NATS-bridged live output' },
|
{ text: 'Real-time console', note: 'NATS-bridged live output' },
|
||||||
{ text: 'File manager', note: 'Browser-based file access via the agent' },
|
{ text: 'File manager', note: 'Browser-based file access via re-Agent' },
|
||||||
{ text: 'Scheduled tasks and maintenance windows' },
|
{ text: 'Scheduled tasks and maintenance windows' },
|
||||||
{ text: 'Player management and RBAC team access' },
|
{ text: 'Player management and RBAC team access' },
|
||||||
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
|
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
|
||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user