feat(guild-events): add HMAC-signed webhook delivery and replay guards
This commit is contained in:
@@ -1,10 +1,25 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { randomUUID } from 'crypto';
|
import { createHmac, randomUUID } from 'crypto';
|
||||||
import { FabricEventEnvelope } from './event-envelope';
|
import { FabricEventEnvelope } from './event-envelope';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventsService {
|
export class EventsService {
|
||||||
private readonly logger = new Logger(EventsService.name);
|
private readonly logger = new Logger(EventsService.name);
|
||||||
|
private readonly sentEventIds = new Map<string, number>();
|
||||||
|
|
||||||
|
private cleanupSentCache(now: number): void {
|
||||||
|
const ttlMs = 10 * 60 * 1000;
|
||||||
|
for (const [eventId, ts] of this.sentEventIds.entries()) {
|
||||||
|
if (now - ts > ttlMs) this.sentEventIds.delete(eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private signWebhook(payload: string, timestamp: string, nonce: string): string {
|
||||||
|
const secret = process.env.FABRIC_WEBHOOK_SECRET;
|
||||||
|
if (!secret) return '';
|
||||||
|
const canonical = ['POST', '/webhook/events', timestamp, nonce, payload].join('\n');
|
||||||
|
return createHmac('sha256', secret).update(canonical).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
buildEnvelope(input: {
|
buildEnvelope(input: {
|
||||||
eventType: string;
|
eventType: string;
|
||||||
@@ -32,21 +47,39 @@ export class EventsService {
|
|||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}): Promise<FabricEventEnvelope> {
|
}): Promise<FabricEventEnvelope> {
|
||||||
const envelope = this.buildEnvelope(input);
|
const envelope = this.buildEnvelope(input);
|
||||||
|
const now = Date.now();
|
||||||
|
this.cleanupSentCache(now);
|
||||||
|
|
||||||
|
if (this.sentEventIds.has(envelope.event_id)) {
|
||||||
|
this.logger.warn(`skip duplicate event_id: ${envelope.event_id}`);
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
|
const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
|
||||||
if (!webhookUrl) {
|
if (!webhookUrl) {
|
||||||
this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`);
|
this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`);
|
||||||
|
this.sentEventIds.set(envelope.event_id, now);
|
||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const timestamp = new Date(now).toISOString();
|
||||||
|
const nonce = randomUUID();
|
||||||
|
const payload = JSON.stringify(envelope);
|
||||||
|
const signature = this.signWebhook(payload, timestamp, nonce);
|
||||||
|
|
||||||
await fetch(webhookUrl, {
|
await fetch(webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
|
'x-fabric-version': '1',
|
||||||
|
'x-fabric-timestamp': timestamp,
|
||||||
|
'x-fabric-nonce': nonce,
|
||||||
|
'x-fabric-signature': signature,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(envelope),
|
body: payload,
|
||||||
});
|
});
|
||||||
|
this.sentEventIds.set(envelope.event_id, now);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.warn(`event delivery failed: ${message}`);
|
this.logger.warn(`event delivery failed: ${message}`);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
|
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
|
||||||
- [x] Webhook 事件信封落地(event_id/event_type/occurred_at/data)
|
- [x] Webhook 事件信封落地(event_id/event_type/occurred_at/data)
|
||||||
- [ ] HMAC 签名与重放防护
|
- [x] HMAC 签名与重放防护
|
||||||
- [ ] 出站重试队列(指数退避)
|
- [ ] 出站重试队列(指数退避)
|
||||||
- [ ] Bot Token 入站调用鉴权
|
- [ ] Bot Token 入站调用鉴权
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user