feat(api): outbound webhooks — server_down + player_banned events
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:
@@ -48,6 +48,7 @@ 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 { 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';
|
||||||
@@ -139,6 +140,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
FleetModule,
|
FleetModule,
|
||||||
InstancesModule,
|
InstancesModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
|
WebhooksModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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,6 +140,22 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {}
|
||||||
220
backend-nest/src/modules/webhooks/webhooks.service.ts
Normal file
220
backend-nest/src/modules/webhooks/webhooks.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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);
|
||||||
Reference in New Issue
Block a user