From 0effaaf86c6287664ff2ae33de60ee35897541b0 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Fri, 12 Jun 2026 02:13:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20outbound=20webhooks=20=E2=80=94=20?= =?UTF-8?q?server=5Fdown=20+=20player=5Fbanned=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 'Webhook events': per-license outbound webhooks with HMAC-SHA256 signatures (X-Corrosion-Signature), 5s timeout, fire-and-forget (a webhook failure never breaks the triggering action), last_delivery_at/last_status tracked. - migration 024_webhooks; Webhook entity (events as simple-array); WebhooksModule (@Global, exports WebhooksService) wired into app.module; CRUD controller (license-scoped, webhooks.view/manage). - Hooked events: players.performAction ban -> 'player_banned'; host-agent-consumer going-offline + staleness sweep -> 'server_down'. - 'wipe_completed' event lands next (needs wipe status from the agent reply). Backend tsc green. Migration applies on a fresh DB (Saturday). Co-Authored-By: Claude Opus 4.8 --- backend-nest/src/app.module.ts | 2 + backend-nest/src/entities/webhook.entity.ts | 47 ++++ .../src/modules/players/players.service.ts | 18 ++ .../webhooks/dto/create-webhook.dto.ts | 33 +++ .../webhooks/dto/update-webhook.dto.ts | 31 +++ .../modules/webhooks/webhooks.controller.ts | 70 ++++++ .../src/modules/webhooks/webhooks.module.ts | 14 ++ .../src/modules/webhooks/webhooks.service.ts | 220 ++++++++++++++++++ .../services/host-agent-consumer.service.ts | 46 ++++ backend/migrations/024_webhooks.sql | 26 +++ 10 files changed, 507 insertions(+) create mode 100644 backend-nest/src/entities/webhook.entity.ts create mode 100644 backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts create mode 100644 backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts create mode 100644 backend-nest/src/modules/webhooks/webhooks.controller.ts create mode 100644 backend-nest/src/modules/webhooks/webhooks.module.ts create mode 100644 backend-nest/src/modules/webhooks/webhooks.service.ts create mode 100644 backend/migrations/024_webhooks.sql diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index 3935ec1..b46af0e 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -48,6 +48,7 @@ import { EarlyAccessModule } from './modules/early-access/early-access.module'; import { FleetModule } from './modules/fleet/fleet.module'; import { InstancesModule } from './modules/instances/instances.module'; import { ApiKeysModule } from './modules/api-keys/api-keys.module'; +import { WebhooksModule } from './modules/webhooks/webhooks.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -139,6 +140,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; FleetModule, InstancesModule, ApiKeysModule, + WebhooksModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/entities/webhook.entity.ts b/backend-nest/src/entities/webhook.entity.ts new file mode 100644 index 0000000..c326152 --- /dev/null +++ b/backend-nest/src/entities/webhook.entity.ts @@ -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; +} diff --git a/backend-nest/src/modules/players/players.service.ts b/backend-nest/src/modules/players/players.service.ts index a998661..6399d64 100644 --- a/backend-nest/src/modules/players/players.service.ts +++ b/backend-nest/src/modules/players/players.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { PlayerAction } from '../../entities/player-action.entity'; import { PlayerSession } from '../../entities/player-session.entity'; import { InstancesService } from '../instances/instances.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { PlayerActionDto } from './dto/player-action.dto'; export interface Player { @@ -24,6 +25,7 @@ export class PlayersService { @InjectRepository(PlayerSession) private readonly sessionRepo: Repository, private readonly instancesService: InstancesService, + private readonly webhooksService: WebhooksService, ) {} /** @@ -138,6 +140,22 @@ export class PlayersService { await this.instancesService.rconForLicense(licenseId, rconCmd); } + // Fire webhook event for player bans. Fire-and-forget — a delivery failure + // must never surface to the caller or roll back the ban action. + if (dto.action_type === 'ban') { + void this.webhooksService + .dispatch(licenseId, 'player_banned', { + steam_id: dto.steam_id, + player_name: dto.player_name, + reason: dto.reason ?? null, + duration_minutes: dto.duration_minutes ?? null, + }) + .catch(() => { + // dispatch() already logs internally; swallow here to guarantee + // the ban action result is unaffected. + }); + } + return { success: true }; } diff --git a/backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts b/backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts new file mode 100644 index 0000000..0e46707 --- /dev/null +++ b/backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts @@ -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; +} diff --git a/backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts b/backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts new file mode 100644 index 0000000..7ee3186 --- /dev/null +++ b/backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts @@ -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; +} diff --git a/backend-nest/src/modules/webhooks/webhooks.controller.ts b/backend-nest/src/modules/webhooks/webhooks.controller.ts new file mode 100644 index 0000000..09d5535 --- /dev/null +++ b/backend-nest/src/modules/webhooks/webhooks.controller.ts @@ -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); + } +} diff --git a/backend-nest/src/modules/webhooks/webhooks.module.ts b/backend-nest/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 0000000..d39adc1 --- /dev/null +++ b/backend-nest/src/modules/webhooks/webhooks.module.ts @@ -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 {} diff --git a/backend-nest/src/modules/webhooks/webhooks.service.ts b/backend-nest/src/modules/webhooks/webhooks.service.ts new file mode 100644 index 0000000..864dbb4 --- /dev/null +++ b/backend-nest/src/modules/webhooks/webhooks.service.ts @@ -0,0 +1,220 @@ +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'; + +/** 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, + ) {} + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + async create(licenseId: string, dto: CreateWebhookDto): Promise { + // 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 { + 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 { + const webhook = await this.findOwned(licenseId, id); + + 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= + * where hex = HMAC-SHA256(rawBody, webhook.secret). + */ + async dispatch( + licenseId: string, + event: string, + payload: Record, + ): Promise { + 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 { + const signature = this.sign(body, hook.secret); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + + let status: 'ok' | 'failed' = 'failed'; + + try { + const res = await fetch(hook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Corrosion-Signature': `sha256=${signature}`, + }, + body, + signal: controller.signal, + }); + + 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 { + 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, + }; + } +} diff --git a/backend-nest/src/services/host-agent-consumer.service.ts b/backend-nest/src/services/host-agent-consumer.service.ts index 1248581..e1f575c 100644 --- a/backend-nest/src/services/host-agent-consumer.service.ts +++ b/backend-nest/src/services/host-agent-consumer.service.ts @@ -7,6 +7,7 @@ import { ServerConnection } from '../entities/server-connection.entity'; import { License } from '../entities/license.entity'; import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity'; import { GameInstance } from '../entities/game-instance.entity'; +import { WebhooksService } from '../modules/webhooks/webhooks.service'; /** * Consumes Corrosion wire protocol v2 host-agent subjects @@ -64,6 +65,7 @@ export class HostAgentConsumerService implements OnApplicationBootstrap { private readonly hostRepository: Repository, @InjectRepository(GameInstance) private readonly instanceRepository: Repository, + private readonly webhooksService: WebhooksService, ) {} // Bootstrap, not module-init: subscriptions registered before NatsService @@ -197,22 +199,52 @@ export class HostAgentConsumerService implements OnApplicationBootstrap { { license_id: licenseId }, { connection_status: 'offline', updated_at: now }, ); + + // Capture hostname(s) before flipping status so the webhook payload is useful. + const hosts = await this.hostRepository.find({ where: { license_id: licenseId } }); + await this.hostRepository.update( { license_id: licenseId }, { status: 'offline', updated_at: now }, ); this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`); + + // Dispatch server_down event for each host that went offline. Fire-and-forget. + for (const host of hosts) { + void this.webhooksService + .dispatch(licenseId, 'server_down', { + host_id: host.id, + hostname: host.hostname ?? null, + reason: 'graceful_shutdown', + }) + .catch(() => { + // dispatch() logs internally; swallow here to keep the handler clean. + }); + } } /** * Heartbeats stopping must flip the panel to offline — an agent that * crashes or loses network never sends the goodbye beacon. Sweeps both the * legacy connection and fleet hosts. + * + * Hosts that transition to offline here also fire the server_down webhook. + * We identify them BEFORE the bulk update so we can carry their identity + * into the webhook payload. */ @Interval(60_000) async sweepStaleConnections(): Promise { const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS); + // Identify stale hosts BEFORE bulk-updating so we can dispatch webhooks + // with meaningful host_id / hostname data. + const staleHosts = await this.hostRepository + .createQueryBuilder('host') + .where('host.status = :connected', { connected: 'connected' }) + .andWhere('host.last_heartbeat_at IS NOT NULL') + .andWhere('host.last_heartbeat_at < :threshold', { threshold }) + .getMany(); + const conn = await this.connectionRepository .createQueryBuilder() .update(ServerConnection) @@ -235,6 +267,20 @@ export class HostAgentConsumerService implements OnApplicationBootstrap { if (affected) { this.logger.warn(`marked ${affected} stale connection/host record(s) offline`); } + + // Dispatch server_down webhook for each host that just timed out. + // Fire-and-forget — webhook failures must never break the sweep. + for (const host of staleHosts) { + void this.webhooksService + .dispatch(host.license_id, 'server_down', { + host_id: host.id, + hostname: host.hostname ?? null, + reason: 'heartbeat_timeout', + }) + .catch(() => { + // dispatch() logs internally; swallow here to keep the sweep clean. + }); + } } /** diff --git a/backend/migrations/024_webhooks.sql b/backend/migrations/024_webhooks.sql new file mode 100644 index 0000000..0aa2b2b --- /dev/null +++ b/backend/migrations/024_webhooks.sql @@ -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);