feat(api): outbound webhooks — server_down + player_banned events
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped

Roadmap 'Webhook events': per-license outbound webhooks with HMAC-SHA256
signatures (X-Corrosion-Signature), 5s timeout, fire-and-forget (a webhook
failure never breaks the triggering action), last_delivery_at/last_status
tracked.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-12 02:13:13 -04:00
parent 55c9893131
commit 0effaaf86c
10 changed files with 507 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,47 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { License } from './license.entity';
@Entity('webhooks')
@Index(['license_id'])
export class Webhook {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text' })
url: string;
/**
* Comma-separated event keys stored as plain text in Postgres.
* TypeORM simple-array serialises string[] ↔ 'event1,event2' automatically.
*/
@Column({ type: 'simple-array' })
events: string[];
/** HMAC-SHA256 signing secret. Auto-generated on create if omitted. */
@Column({ type: 'varchar', length: 128 })
secret: string;
@Column({ type: 'boolean', default: true })
is_active: boolean;
/** Timestamp of the most recent delivery attempt (success or failure). */
@Column({ type: 'timestamptz', nullable: true })
last_delivery_at: Date | null;
/** 'ok' | 'failed' — outcome of the most recent delivery attempt. */
@Column({ type: 'varchar', length: 20, nullable: true })
last_status: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

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

View File

@@ -0,0 +1,33 @@
import { IsString, IsNotEmpty, IsUrl, IsArray, ArrayNotEmpty, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateWebhookDto {
@ApiProperty({ description: 'Human-readable label for this webhook', maxLength: 100 })
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiProperty({ description: 'HTTPS URL to POST events to' })
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
url: string;
@ApiProperty({
description: 'Event keys to subscribe to',
example: ['player_banned', 'server_down'],
type: [String],
})
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
events: string[];
@ApiPropertyOptional({
description: 'HMAC-SHA256 signing secret. Auto-generated if omitted.',
maxLength: 128,
})
@IsOptional()
@IsString()
@MaxLength(128)
secret?: string;
}

View File

@@ -0,0 +1,31 @@
import { IsString, IsUrl, IsArray, ArrayNotEmpty, IsOptional, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateWebhookDto {
@ApiPropertyOptional({ description: 'Human-readable label for this webhook', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@ApiPropertyOptional({ description: 'HTTPS URL to POST events to' })
@IsOptional()
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
url?: string;
@ApiPropertyOptional({
description: 'Event keys to subscribe to',
example: ['player_banned', 'server_down'],
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
events?: string[];
@ApiPropertyOptional({ description: 'Enable or disable this webhook' })
@IsOptional()
@IsBoolean()
is_active?: boolean;
}

View File

@@ -0,0 +1,70 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { WebhooksService } from './webhooks.service';
import { CreateWebhookDto } from './dto/create-webhook.dto';
import { UpdateWebhookDto } from './dto/update-webhook.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('webhooks')
@ApiBearerAuth()
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}
@Post()
@RequirePermission('webhooks.manage')
@ApiOperation({
summary: 'Create a webhook',
description:
'Registers a new outbound webhook for this license. A signing secret is auto-generated if not provided.',
})
@ApiResponse({ status: 201, description: 'Webhook created.' })
async create(
@CurrentTenant() licenseId: string,
@Body() dto: CreateWebhookDto,
) {
return this.webhooksService.create(licenseId, dto);
}
@Get()
@RequirePermission('webhooks.view')
@ApiOperation({ summary: 'List webhooks', description: 'Returns all webhooks for this license.' })
@ApiResponse({ status: 200, description: 'Webhook list.' })
async list(@CurrentTenant() licenseId: string) {
return this.webhooksService.list(licenseId);
}
@Patch(':id')
@RequirePermission('webhooks.manage')
@ApiOperation({ summary: 'Update a webhook', description: 'Update name, URL, event subscriptions, or active state.' })
@ApiResponse({ status: 200, description: 'Webhook updated.' })
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
async update(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateWebhookDto,
) {
return this.webhooksService.update(licenseId, id, dto);
}
@Delete(':id')
@RequirePermission('webhooks.manage')
@ApiOperation({ summary: 'Delete a webhook' })
@ApiResponse({ status: 200, description: 'Webhook deleted.' })
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
async remove(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
) {
return this.webhooksService.remove(licenseId, id);
}
}

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Webhook } from '../../entities/webhook.entity';
import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([Webhook])],
controllers: [WebhooksController],
providers: [WebhooksService],
exports: [WebhooksService],
})
export class WebhooksModule {}

View File

@@ -0,0 +1,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<Webhook>,
) {}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async create(licenseId: string, dto: CreateWebhookDto): Promise<CreatedWebhook> {
// 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);
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 {
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<Webhook> {
const webhook = await this.webhookRepo.findOne({
where: { id, license_id: licenseId },
});
if (!webhook) {
throw new NotFoundException(`Webhook ${id} not found`);
}
return webhook;
}
private sign(body: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(body).digest('hex');
}
private toListItem(w: Webhook): WebhookListItem {
return {
id: w.id,
name: w.name,
url: w.url,
events: w.events,
secret: w.secret,
is_active: w.is_active,
last_delivery_at: w.last_delivery_at,
last_status: w.last_status,
created_at: w.created_at,
};
}
}

View File

@@ -7,6 +7,7 @@ import { ServerConnection } from '../entities/server-connection.entity';
import { License } from '../entities/license.entity';
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
import { GameInstance } from '../entities/game-instance.entity';
import { WebhooksService } from '../modules/webhooks/webhooks.service';
/**
* Consumes Corrosion wire protocol v2 host-agent subjects
@@ -64,6 +65,7 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
private readonly hostRepository: Repository<AgentHost>,
@InjectRepository(GameInstance)
private readonly instanceRepository: Repository<GameInstance>,
private readonly webhooksService: WebhooksService,
) {}
// Bootstrap, not module-init: subscriptions registered before NatsService
@@ -197,22 +199,52 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
{ license_id: licenseId },
{ connection_status: 'offline', updated_at: now },
);
// Capture hostname(s) before flipping status so the webhook payload is useful.
const hosts = await this.hostRepository.find({ where: { license_id: licenseId } });
await this.hostRepository.update(
{ license_id: licenseId },
{ status: 'offline', updated_at: now },
);
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
// Dispatch server_down event for each host that went offline. Fire-and-forget.
for (const host of hosts) {
void this.webhooksService
.dispatch(licenseId, 'server_down', {
host_id: host.id,
hostname: host.hostname ?? null,
reason: 'graceful_shutdown',
})
.catch(() => {
// dispatch() logs internally; swallow here to keep the handler clean.
});
}
}
/**
* Heartbeats stopping must flip the panel to offline — an agent that
* crashes or loses network never sends the goodbye beacon. Sweeps both the
* legacy connection and fleet hosts.
*
* Hosts that transition to offline here also fire the server_down webhook.
* We identify them BEFORE the bulk update so we can carry their identity
* into the webhook payload.
*/
@Interval(60_000)
async sweepStaleConnections(): Promise<void> {
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
// Identify stale hosts BEFORE bulk-updating so we can dispatch webhooks
// with meaningful host_id / hostname data.
const staleHosts = await this.hostRepository
.createQueryBuilder('host')
.where('host.status = :connected', { connected: 'connected' })
.andWhere('host.last_heartbeat_at IS NOT NULL')
.andWhere('host.last_heartbeat_at < :threshold', { threshold })
.getMany();
const conn = await this.connectionRepository
.createQueryBuilder()
.update(ServerConnection)
@@ -235,6 +267,20 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
if (affected) {
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
}
// Dispatch server_down webhook for each host that just timed out.
// Fire-and-forget — webhook failures must never break the sweep.
for (const host of staleHosts) {
void this.webhooksService
.dispatch(host.license_id, 'server_down', {
host_id: host.id,
hostname: host.hostname ?? null,
reason: 'heartbeat_timeout',
})
.catch(() => {
// dispatch() logs internally; swallow here to keep the sweep clean.
});
}
}
/**