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