refactor: per-subscriber KV keys, HMAC verification, cron trigger

Major refactor addressing scalability, security, and reliability:

- KV schema: single-key → per-subscriber keys (sub:{chatId}:{threadId})
  eliminates read-modify-write race conditions
- Component-specific subscriptions: /subscribe component <name>
- HMAC-SHA256 webhook verification with URL secret fallback
- Cron trigger (every 5 min) polls status.claude.com as safety net
- Shared telegram-api.js module (DRY fix)
- Error logging in all catch blocks
- Migration endpoint for existing subscribers
- Setup moved to standalone script (scripts/setup-bot.js)
- Removed setup HTTP route to reduce attack surface
This commit is contained in:
2026-04-09 00:43:07 +07:00
parent 30ffaae612
commit b728ae7d38
12 changed files with 443 additions and 117 deletions

View File

@@ -1,6 +1,49 @@
import { getSubscribersByType } from "./kv-store.js";
import { humanizeStatus, escapeHtml } from "./status-fetcher.js";
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
/**
* Verify Statuspage HMAC-SHA256 signature
*/
async function verifyHmacSignature(request, hmacKey) {
if (!hmacKey) return false;
const signature = request.headers.get("X-Statuspage-Signature");
if (!signature) return false;
const body = await request.clone().arrayBuffer();
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(hmacKey),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const sigBytes = hexToBytes(signature);
return crypto.subtle.verify("HMAC", key, sigBytes, body);
}
/**
* Timing-safe string comparison
*/
async function timingSafeEqual(a, b) {
const encoder = new TextEncoder();
const bufA = encoder.encode(a);
const bufB = encoder.encode(b);
if (bufA.byteLength !== bufB.byteLength) return false;
return crypto.subtle.timingSafeEqual(bufA, bufB);
}
/**
* Format incident event as Telegram HTML message
*/
@@ -36,13 +79,13 @@ function formatComponentMessage(component, update) {
* Handle incoming Statuspage webhook
*/
export async function handleStatuspageWebhook(c) {
// Validate secret (timing-safe comparison)
const secret = c.req.param("secret");
const encoder = new TextEncoder();
const a = encoder.encode(secret);
const b = encoder.encode(c.env.WEBHOOK_SECRET);
if (a.byteLength !== b.byteLength || !crypto.subtle.timingSafeEqual(a, b)) {
return c.text("Unauthorized", 401);
// Try HMAC verification first, fall back to URL secret
const hmacValid = await verifyHmacSignature(c.req.raw, c.env.STATUSPAGE_HMAC_KEY);
if (!hmacValid) {
const secret = c.req.param("secret");
if (!await timingSafeEqual(secret, c.env.WEBHOOK_SECRET)) {
return c.text("Unauthorized", 401);
}
}
// Parse body
@@ -56,20 +99,23 @@ export async function handleStatuspageWebhook(c) {
const eventType = body?.meta?.event_type;
if (!eventType) return c.text("Bad Request", 400);
console.log(`Statuspage webhook: ${eventType}`);
// Determine category and format message
let category, html;
let category, html, componentName;
if (eventType.startsWith("incident.")) {
category = "incident";
html = formatIncidentMessage(body.incident);
} else if (eventType.startsWith("component.")) {
category = "component";
componentName = body.component?.name || null;
html = formatComponentMessage(body.component, body.component_update);
} else {
return c.text("Unknown event type", 400);
}
// Get filtered subscribers
const subscribers = await getSubscribersByType(c.env.claude_status, category);
// Get filtered subscribers (with component name filtering)
const subscribers = await getSubscribersByType(c.env.claude_status, category, componentName);
// Enqueue messages for fan-out via CF Queues (batch for performance)
const messages = subscribers.map(({ chatId, threadId }) => ({
@@ -79,5 +125,7 @@ export async function handleStatuspageWebhook(c) {
await c.env["claude-status"].sendBatch(messages.slice(i, i + 100));
}
console.log(`Enqueued ${messages.length} messages for ${category}${componentName ? `:${componentName}` : ""}`);
return c.text("OK", 200);
}