hookarmor 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -20
- package/bin/hookarmor.js +33 -4
- package/package.json +1 -1
- package/src/dispatcher.js +46 -2
- package/src/public/index.html +5 -2
- package/src/server.js +105 -6
- package/src/storage.js +168 -31
- package/src/worker.js +8 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# 🛡️ HookArmor
|
|
2
2
|
|
|
3
|
-
> **
|
|
3
|
+
> **Durable at-least-once Webhook Dead-Letter Queue (DLQ), Reliability Proxy, and Replay Gateway for Stripe, Shopify, Clerk, and modern B2B SaaS.**
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[]()
|
|
7
|
+
[]()
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -26,8 +26,8 @@ Every developer using Stripe, Shopify, GitHub, Clerk, Paddle, or custom webhooks
|
|
|
26
26
|
▼
|
|
27
27
|
┌───────────────────────────────────────┐
|
|
28
28
|
│ HookArmor Ingress Edge │
|
|
29
|
-
│ - Returns immediate 200 OK
|
|
30
|
-
│ - Stores raw payload in SQLite
|
|
29
|
+
│ - Returns immediate 200 OK │
|
|
30
|
+
│ - Stores raw payload in SQLite WAL │
|
|
31
31
|
└───────────────────┬───────────────────┘
|
|
32
32
|
│
|
|
33
33
|
▼
|
|
@@ -48,7 +48,7 @@ Every developer using Stripe, Shopify, GitHub, Clerk, Paddle, or custom webhooks
|
|
|
48
48
|
└─────────────────────────────────────┘
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
1. **
|
|
51
|
+
1. **Immediate Durable Ingest**: HookArmor returns a `200 OK` once committed to durable SQLite storage so Stripe or Shopify never abandons the webhook.
|
|
52
52
|
2. **Cryptographic Header & Signature Preservation**: Forwards exact raw bytes, `stripe-signature`, `x-shopify-hmac-sha256`, and timestamps.
|
|
53
53
|
3. **Dead-Letter Queue (DLQ)**: If your server returns 500, 502, 504, 429, or times out, HookArmor safely preserves the raw event with full error diagnostics.
|
|
54
54
|
4. **Instant Alerts**: Sends immediate Slack / Discord webhooks when an endpoint begins failing.
|
|
@@ -105,7 +105,7 @@ npx hookarmor replay evt_1758513516086_m8r0e7
|
|
|
105
105
|
|
|
106
106
|
## 📊 Verification Test Suite
|
|
107
107
|
|
|
108
|
-
HookArmor includes an 8-scenario verification suite (`test/verify.js`) plus a
|
|
108
|
+
HookArmor includes an 8-scenario verification suite (`test/verify.js`) plus a 39-case regression suite (`test/regression.js`) covering retry scheduling, crash recovery, multi-instance lease claims, cascading deletion, duplicate suppression, signature verification for every supported provider, outbound address validation, authentication, and dashboard escaping:
|
|
109
109
|
```bash
|
|
110
110
|
npm test
|
|
111
111
|
```
|
|
@@ -222,24 +222,47 @@ HookArmor returns an immediate `200 OK` to Stripe and Shopify in `<10ms` to prot
|
|
|
222
222
|
|
|
223
223
|
---
|
|
224
224
|
|
|
225
|
-
##
|
|
225
|
+
## 🏗️ Architecture & Operational Scope
|
|
226
226
|
|
|
227
|
-
HookArmor is 100% free and open-source under the MIT license
|
|
227
|
+
HookArmor is 100% free and open-source under the MIT license, architected specifically as a **single-node, low-footprint buffer and sidecar** for monolithic and containerized applications.
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
| **Instant 200 OK Ack** | Yes (<10ms) | Yes (<10ms) | Yes (<10ms) |
|
|
233
|
-
| **Dead-Letter Queue (DLQ)** | Yes | Yes | Yes |
|
|
234
|
-
| **Stripe Signature Re-Signing** | Yes | Yes | Yes |
|
|
235
|
-
| **Background Auto-Retries** | Yes | Yes | Yes |
|
|
236
|
-
| **Concurrency Pool Limiter** | Yes | Yes | Yes |
|
|
237
|
-
| **Event Retention** | Local Disk (Configurable) | 30 days | 90 days |
|
|
238
|
-
| **Alerting** | Discord / Slack Webhooks | Discord / Slack Webhooks | Priority Webhooks |
|
|
239
|
-
| **Infrastructure** | Single-node Docker / VPS | Fully managed & redundant | Fully managed & redundant |
|
|
229
|
+
* **Single-Process Simplicity**: Operates entirely in a single Node.js process using embedded SQLite with Write-Ahead Logging (`WAL`) and `synchronous=FULL`. It consumes <50MB RAM and eliminates the operational overhead of running external message brokers (Kafka, RabbitMQ, SQS, or Redis).
|
|
230
|
+
* **Scope & Boundaries**: HookArmor is intended to run on the same VPS, container host, or private network cluster as your downstream web application. It is **not** a distributed multi-region cluster broker.
|
|
231
|
+
* **Persistent Disk Required**: Because events are durably acknowledged to providers in `<5ms`, your container volume (`/app/data`) must be backed by a persistent disk or volume mount.
|
|
240
232
|
|
|
241
233
|
---
|
|
242
234
|
|
|
235
|
+
## 📊 Prometheus & Grafana Metrics
|
|
236
|
+
|
|
237
|
+
HookArmor includes a native, zero-dependency Prometheus exposition endpoint at `GET /metrics`. Scrape this endpoint into your existing Prometheus or VictoriaMetrics instance to monitor webhook health:
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
# Scraping: http://localhost:4000/metrics
|
|
241
|
+
hookarmor_uptime_seconds 8432
|
|
242
|
+
hookarmor_events_total{status="delivered"} 1420
|
|
243
|
+
hookarmor_events_total{status="failed"} 12
|
|
244
|
+
hookarmor_events_total{status="pending"} 0
|
|
245
|
+
hookarmor_endpoints_total 4
|
|
246
|
+
hookarmor_endpoints_unverified_total 1
|
|
247
|
+
hookarmor_delivery_latency_ms_avg 34.2
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 🔒 Secret Encryption at Rest (AES-256-GCM)
|
|
253
|
+
|
|
254
|
+
By default, endpoint signing secrets are stored in SQLite. For hardened environments, set `HOOKARMOR_ENCRYPTION_KEY` to enable transparent **AES-256-GCM authenticated envelope encryption** at rest:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
# Generate a 256-bit encryption key
|
|
258
|
+
openssl rand -hex 32
|
|
259
|
+
|
|
260
|
+
# Set in your environment:
|
|
261
|
+
export HOOKARMOR_ENCRYPTION_KEY=your_64_char_hex_key
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
When enabled, all provider webhook secrets are encrypted with a unique 96-bit random IV and 128-bit authentication tag before being written to disk (`enc:v1:iv:tag:ciphertext`), preventing secret extraction even if database files or backups are compromised.
|
|
265
|
+
|
|
243
266
|
## 🔐 Production Security & Admin Authentication
|
|
244
267
|
|
|
245
268
|
When running HookArmor locally on your laptop (`localhost`), management endpoints and the replay dashboard operate without authentication for fast developer onboarding.
|
|
@@ -275,6 +298,7 @@ When `HOOKARMOR_API_KEY` is present:
|
|
|
275
298
|
| Variable | Default | Purpose |
|
|
276
299
|
|---|---|---|
|
|
277
300
|
| `HOOKARMOR_API_KEY` | none (required in production) | Admin key for the management API, dashboard and CLI |
|
|
301
|
+
| `HOOKARMOR_ENCRYPTION_KEY` | none | 256-bit key enabling AES-256-GCM at-rest encryption for endpoint secrets |
|
|
278
302
|
| `HOOKARMOR_STRICT_SSRF` | `true` in production, `false` locally | Block loopback, private-network and link-local delivery targets (checked on the resolved IP at delivery time). Cloud metadata addresses are always blocked |
|
|
279
303
|
| `HOOKARMOR_SIGNING_SECRET` | none | Enables Mode B internal signatures |
|
|
280
304
|
| `HOOKARMOR_DATA_DIR` | `./data` (current directory) | Where `hookarmor.db` is stored |
|
package/bin/hookarmor.js
CHANGED
|
@@ -22,6 +22,28 @@ function startServerOrExit() {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function setupGracefulShutdown(server, dispatcher, storage) {
|
|
26
|
+
let shuttingDown = false;
|
|
27
|
+
const shutdown = async (signal) => {
|
|
28
|
+
if (shuttingDown) return;
|
|
29
|
+
shuttingDown = true;
|
|
30
|
+
console.log(chalk.yellow(`\n⏻ ${signal} received: stopping incoming traffic and draining in-flight webhooks...`));
|
|
31
|
+
server.close();
|
|
32
|
+
if (dispatcher && dispatcher.drain) {
|
|
33
|
+
await dispatcher.drain(15000);
|
|
34
|
+
}
|
|
35
|
+
if (storage && storage.db) {
|
|
36
|
+
try {
|
|
37
|
+
storage.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
38
|
+
} catch (_) {}
|
|
39
|
+
}
|
|
40
|
+
console.log(chalk.green('✔ Shutdown complete.'));
|
|
41
|
+
process.exit(0);
|
|
42
|
+
};
|
|
43
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
44
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
function apiHeaders(apiKey) {
|
|
26
48
|
const headers = { 'Content-Type': 'application/json' };
|
|
27
49
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
@@ -45,7 +67,8 @@ program
|
|
|
45
67
|
.action((options) => {
|
|
46
68
|
const port = parseInt(options.port, 10);
|
|
47
69
|
const host = options.host;
|
|
48
|
-
const { server, storage, mockEnabled } = startServerOrExit();
|
|
70
|
+
const { server, storage, dispatcher, mockEnabled } = startServerOrExit();
|
|
71
|
+
setupGracefulShutdown(server, dispatcher, storage);
|
|
49
72
|
|
|
50
73
|
// Ensure a default mock endpoint exists for first-time onboarding (local simulation only)
|
|
51
74
|
if (mockEnabled && !storage.getEndpoint('demo-stripe')) {
|
|
@@ -78,11 +101,17 @@ program
|
|
|
78
101
|
.description('Tunnel incoming webhooks directly to a local or remote target')
|
|
79
102
|
.argument('<targetUrl>', 'Destination target URL (e.g. http://localhost:3000/api/webhook)')
|
|
80
103
|
.option('-p, --port <number>', 'Proxy port', '4000')
|
|
104
|
+
.option('-H, --host <host>', 'Host address to bind to', '127.0.0.1')
|
|
81
105
|
.option('-e, --endpoint <id>', 'Endpoint ID slug', 'local-dev')
|
|
82
106
|
.option('-s, --secret <secret>', 'Provider signing secret: verify on ingress and re-sign on delivery')
|
|
83
107
|
.action(async (targetUrl, options) => {
|
|
84
108
|
const port = parseInt(options.port, 10);
|
|
109
|
+
const host = options.host || '127.0.0.1';
|
|
110
|
+
if (host === '0.0.0.0') {
|
|
111
|
+
console.warn(chalk.yellow('\n[Security Warning] hookarmor listen is bound to 0.0.0.0 — reachable by anyone on your local network. Use 127.0.0.1 for local isolation.'));
|
|
112
|
+
}
|
|
85
113
|
const { server, storage, dispatcher } = startServerOrExit();
|
|
114
|
+
setupGracefulShutdown(server, dispatcher, storage);
|
|
86
115
|
|
|
87
116
|
storage.createEndpoint({
|
|
88
117
|
id: options.endpoint,
|
|
@@ -103,10 +132,10 @@ program
|
|
|
103
132
|
}
|
|
104
133
|
});
|
|
105
134
|
|
|
106
|
-
server.listen(port, () => {
|
|
135
|
+
server.listen(port, host, () => {
|
|
107
136
|
console.log(chalk.cyan.bold(`\n⚡ HookArmor Listen Active`));
|
|
108
|
-
console.log(`Forwarding: ${chalk.yellow(`http
|
|
109
|
-
console.log(`Dashboard: ${chalk.cyan(`http
|
|
137
|
+
console.log(`Forwarding: ${chalk.yellow(`http://${host}:${port}/in/${options.endpoint}`)} ➔ ${chalk.green(targetUrl)}`);
|
|
138
|
+
console.log(`Dashboard: ${chalk.cyan(`http://${host}:${port}/dashboard`)}\n`);
|
|
110
139
|
});
|
|
111
140
|
});
|
|
112
141
|
|
package/package.json
CHANGED
package/src/dispatcher.js
CHANGED
|
@@ -23,6 +23,24 @@ function svixSignature(secret, msgId, timestamp, rawBody) {
|
|
|
23
23
|
return hmac.digest('base64');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const STRIPPED_INBOUND_HEADERS = new Set([
|
|
27
|
+
'forwarded',
|
|
28
|
+
'x-real-ip',
|
|
29
|
+
'x-original-url',
|
|
30
|
+
'x-rewrite-url',
|
|
31
|
+
'x-http-method-override',
|
|
32
|
+
'cf-connecting-ip',
|
|
33
|
+
'true-client-ip',
|
|
34
|
+
'proxy-authorization',
|
|
35
|
+
'x-forwarded-for',
|
|
36
|
+
'x-forwarded-host',
|
|
37
|
+
'x-forwarded-proto',
|
|
38
|
+
'x-forwarded-port',
|
|
39
|
+
'x-forwarded-server',
|
|
40
|
+
'x-forwarded-prefix',
|
|
41
|
+
'x-forwarded-ssl'
|
|
42
|
+
]);
|
|
43
|
+
|
|
26
44
|
class Dispatcher extends EventEmitter {
|
|
27
45
|
constructor(storage, options = {}) {
|
|
28
46
|
super();
|
|
@@ -33,6 +51,7 @@ class Dispatcher extends EventEmitter {
|
|
|
33
51
|
this.defaultTimeoutMs = options.defaultTimeoutMs || 25000;
|
|
34
52
|
this.strictSSRF = Boolean(options.strictSSRF);
|
|
35
53
|
this.signingSecret = options.signingSecret || null;
|
|
54
|
+
this.activeDeliveries = 0;
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
// Verify signature at ingress (preventing HookArmor from acting as an open signature oracle).
|
|
@@ -204,14 +223,29 @@ class Dispatcher extends EventEmitter {
|
|
|
204
223
|
return new Date(Date.now() + delaySec * 1000).toISOString().slice(0, 19).replace('T', ' ');
|
|
205
224
|
}
|
|
206
225
|
|
|
226
|
+
pendingDeliveries() {
|
|
227
|
+
return this.activeDeliveries;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async drain(timeoutMs = 15000) {
|
|
231
|
+
const start = Date.now();
|
|
232
|
+
while (this.pendingDeliveries() > 0) {
|
|
233
|
+
if (Date.now() - start > timeoutMs) break;
|
|
234
|
+
await new Promise(r => setTimeout(r, 100));
|
|
235
|
+
}
|
|
236
|
+
return this.pendingDeliveries() === 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
207
239
|
// Callers must have claimed the event (status 'replaying') before dispatching
|
|
208
240
|
async dispatch(event, endpoint, { replay = false } = {}) {
|
|
209
241
|
const limit = endpoint.concurrency_limit || this.defaultConcurrency;
|
|
210
242
|
await this.acquireSlot(endpoint.id, limit);
|
|
243
|
+
this.activeDeliveries++;
|
|
211
244
|
|
|
212
245
|
try {
|
|
213
246
|
return await this._executeDispatch(event, endpoint, replay);
|
|
214
247
|
} finally {
|
|
248
|
+
this.activeDeliveries = Math.max(0, this.activeDeliveries - 1);
|
|
215
249
|
this.releaseSlot(endpoint.id, limit);
|
|
216
250
|
}
|
|
217
251
|
}
|
|
@@ -222,9 +256,16 @@ class Dispatcher extends EventEmitter {
|
|
|
222
256
|
|
|
223
257
|
let headers = { ...event.headers };
|
|
224
258
|
|
|
225
|
-
// Never forward sender-supplied HookArmor headers
|
|
259
|
+
// Never forward sender-supplied HookArmor headers or spoofable routing headers
|
|
226
260
|
for (const name of Object.keys(headers)) {
|
|
227
|
-
|
|
261
|
+
const lower = name.toLowerCase();
|
|
262
|
+
if (
|
|
263
|
+
lower.startsWith('x-hookarmor-') ||
|
|
264
|
+
lower.startsWith('x-forwarded-') ||
|
|
265
|
+
STRIPPED_INBOUND_HEADERS.has(lower)
|
|
266
|
+
) {
|
|
267
|
+
delete headers[name];
|
|
268
|
+
}
|
|
228
269
|
}
|
|
229
270
|
|
|
230
271
|
// Preserve original raw signatures before re-signing for audit and forensic trace
|
|
@@ -276,6 +317,9 @@ class Dispatcher extends EventEmitter {
|
|
|
276
317
|
headers['x-hookarmor-original-timestamp'] = originalTimestamp;
|
|
277
318
|
const isReplay = replay || event.attempts > 0;
|
|
278
319
|
headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
|
|
320
|
+
if (!event.verified) {
|
|
321
|
+
headers['x-hookarmor-unverified'] = 'true';
|
|
322
|
+
}
|
|
279
323
|
|
|
280
324
|
return new Promise((resolve) => {
|
|
281
325
|
let settled = false;
|
package/src/public/index.html
CHANGED
|
@@ -900,13 +900,16 @@
|
|
|
900
900
|
|
|
901
901
|
container.innerHTML = cachedEndpoints.map(ep => `
|
|
902
902
|
<div class="endpoint-item">
|
|
903
|
-
<div class="endpoint-name"
|
|
903
|
+
<div class="endpoint-name" style="display: flex; align-items: center; justify-content: space-between;">
|
|
904
|
+
<span>${esc(ep.name)}</span>
|
|
905
|
+
${!ep.has_secret ? '<span class="badge" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); font-size: 0.68rem; padding: 0.1rem 0.4rem;">⚠️ No Secret</span>' : ''}
|
|
906
|
+
</div>
|
|
904
907
|
<div class="endpoint-url" title="Ingress URL">${esc(window.location.origin)}/in/${esc(ep.id)}</div>
|
|
905
908
|
<div class="target-url">🎯 <strong>Target:</strong> ${esc(ep.target_url)}</div>
|
|
906
909
|
<div style="font-size: 0.72rem; color: var(--text-muted); display: flex; gap: 0.75rem;">
|
|
907
910
|
<span>Auto-Retry: <strong>${ep.auto_retry ? 'Yes' : 'No'}</strong></span>
|
|
908
911
|
<span>Max: <strong>${esc(ep.max_retries)} attempts</strong></span>
|
|
909
|
-
<span>Signed: <strong>${ep.has_secret ? 'Yes' : 'No'}</strong></span>
|
|
912
|
+
<span>Signed: <strong>${ep.has_secret ? 'Yes' : '<span style="color: #f59e0b;">No (⚠️ Unverified)</span>'}</strong></span>
|
|
910
913
|
</div>
|
|
911
914
|
</div>
|
|
912
915
|
`).join('');
|
package/src/server.js
CHANGED
|
@@ -90,15 +90,53 @@ function createServer(options = {}) {
|
|
|
90
90
|
const app = express();
|
|
91
91
|
const server = http.createServer(app);
|
|
92
92
|
|
|
93
|
+
app.disable('x-powered-by');
|
|
94
|
+
|
|
95
|
+
app.use((req, res, next) => {
|
|
96
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
97
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
98
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
99
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
100
|
+
res.setHeader(
|
|
101
|
+
'Content-Security-Policy',
|
|
102
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none';"
|
|
103
|
+
);
|
|
104
|
+
const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
105
|
+
if (isHttps) {
|
|
106
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
107
|
+
}
|
|
108
|
+
next();
|
|
109
|
+
});
|
|
110
|
+
|
|
93
111
|
const trustProxy = process.env.HOOKARMOR_TRUST_PROXY || ((process.env.RAILWAY_ENVIRONMENT || process.env.RENDER) ? '1' : null);
|
|
94
112
|
if (trustProxy) app.set('trust proxy', /^\d+$/.test(trustProxy) ? parseInt(trustProxy, 10) : trustProxy);
|
|
95
113
|
|
|
96
|
-
const storage = new Storage(options.dbPath
|
|
97
|
-
|
|
114
|
+
const storage = new Storage(options.dbPath, {
|
|
115
|
+
encryptionKey: options.encryptionKey || process.env.HOOKARMOR_ENCRYPTION_KEY
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const instanceId = options.instanceId || `inst_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
119
|
+
storage.registerInstance({
|
|
120
|
+
id: instanceId,
|
|
121
|
+
hostname: require('os').hostname(),
|
|
122
|
+
pid: process.pid
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const recovered = storage.recoverInterruptedDeliveries(instanceId);
|
|
98
126
|
if (recovered > 0) {
|
|
99
127
|
console.warn(`[HookArmor] Re-queued ${recovered} event(s) that were mid-delivery when the process last stopped.`);
|
|
100
128
|
}
|
|
101
129
|
|
|
130
|
+
// Security audit check: warn about endpoints without secrets on startup
|
|
131
|
+
try {
|
|
132
|
+
const initialEndpoints = storage.listEndpoints();
|
|
133
|
+
for (const ep of initialEndpoints) {
|
|
134
|
+
if (!ep.secret) {
|
|
135
|
+
console.warn(`[Security Warning] Endpoint '${ep.id}' has no signing secret. Incoming webhooks cannot be cryptographically verified against spoofing!`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (_) {}
|
|
139
|
+
|
|
102
140
|
const dispatcher = new Dispatcher(storage, {
|
|
103
141
|
defaultConcurrency: options.defaultConcurrency || 5,
|
|
104
142
|
defaultTimeoutMs: options.defaultTimeoutMs || 25000,
|
|
@@ -108,7 +146,8 @@ function createServer(options = {}) {
|
|
|
108
146
|
|
|
109
147
|
const worker = new RetryWorker(storage, dispatcher, {
|
|
110
148
|
intervalMs: options.retryIntervalMs || 5000,
|
|
111
|
-
retentionDays
|
|
149
|
+
retentionDays,
|
|
150
|
+
instanceId
|
|
112
151
|
});
|
|
113
152
|
if (options.autoStartWorker !== false) {
|
|
114
153
|
worker.start();
|
|
@@ -271,6 +310,9 @@ function createServer(options = {}) {
|
|
|
271
310
|
});
|
|
272
311
|
}
|
|
273
312
|
verified = true;
|
|
313
|
+
} else {
|
|
314
|
+
console.warn(`[Ingress Warning] Ingested webhook for endpoint '${endpoint.id}' without signature verification (no secret configured).`);
|
|
315
|
+
headers['x-hookarmor-unverified'] = 'true';
|
|
274
316
|
}
|
|
275
317
|
|
|
276
318
|
let savedEvent;
|
|
@@ -300,6 +342,7 @@ function createServer(options = {}) {
|
|
|
300
342
|
headers,
|
|
301
343
|
rawBody: rawBodyBuffer,
|
|
302
344
|
status: 'replaying',
|
|
345
|
+
claimedBy: instanceId,
|
|
303
346
|
verified
|
|
304
347
|
});
|
|
305
348
|
} catch (err) {
|
|
@@ -522,13 +565,29 @@ function createServer(options = {}) {
|
|
|
522
565
|
responseBody: { status: 'mock_processed' }
|
|
523
566
|
};
|
|
524
567
|
|
|
568
|
+
const mockAuthMiddleware = (req, res, next) => {
|
|
569
|
+
if (apiKey) {
|
|
570
|
+
const header = req.headers['authorization'] || '';
|
|
571
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : (req.headers['x-api-key'] || req.query.key || '');
|
|
572
|
+
if (!tokenMatches(token)) {
|
|
573
|
+
return res.status(401).json({ error: 'Unauthorized: valid API key required for /mock/config' });
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
const host = hostnameOf(req.headers['host']);
|
|
577
|
+
if (!LOCAL_HOSTNAMES.has(host) && !allowNoAuth) {
|
|
578
|
+
return res.status(403).json({ error: 'Forbidden: loopback access only' });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
next();
|
|
582
|
+
};
|
|
583
|
+
|
|
525
584
|
app.post('/mock/target', (req, res) => {
|
|
526
585
|
setTimeout(() => {
|
|
527
586
|
res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
|
|
528
587
|
}, mockTargetBehavior.delayMs);
|
|
529
588
|
});
|
|
530
589
|
|
|
531
|
-
app.post('/mock/config', (req, res) => {
|
|
590
|
+
app.post('/mock/config', mockAuthMiddleware, (req, res) => {
|
|
532
591
|
const body = req.body || {};
|
|
533
592
|
mockTargetBehavior = {
|
|
534
593
|
statusCode: clampInt(body.statusCode, mockTargetBehavior.statusCode, 100, 599),
|
|
@@ -538,11 +597,51 @@ function createServer(options = {}) {
|
|
|
538
597
|
res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
|
|
539
598
|
});
|
|
540
599
|
|
|
541
|
-
app.get('/mock/config', (req, res) => {
|
|
600
|
+
app.get('/mock/config', mockAuthMiddleware, (req, res) => {
|
|
542
601
|
res.json(mockTargetBehavior);
|
|
543
602
|
});
|
|
544
603
|
}
|
|
545
604
|
|
|
605
|
+
// Prometheus Metrics Exposition Endpoint
|
|
606
|
+
app.get('/metrics', (req, res) => {
|
|
607
|
+
try {
|
|
608
|
+
const stats = storage.getStats();
|
|
609
|
+
const endpoints = storage.listEndpoints();
|
|
610
|
+
const unverifiedCount = endpoints.filter((e) => !e.secret).length;
|
|
611
|
+
const uptime = Math.floor(process.uptime());
|
|
612
|
+
|
|
613
|
+
const lines = [
|
|
614
|
+
'# HELP hookarmor_uptime_seconds Total process uptime in seconds',
|
|
615
|
+
'# TYPE hookarmor_uptime_seconds counter',
|
|
616
|
+
`hookarmor_uptime_seconds ${uptime}`,
|
|
617
|
+
'',
|
|
618
|
+
'# HELP hookarmor_events_total Total number of events by status',
|
|
619
|
+
'# TYPE hookarmor_events_total gauge',
|
|
620
|
+
`hookarmor_events_total{status="delivered"} ${stats.delivered || 0}`,
|
|
621
|
+
`hookarmor_events_total{status="failed"} ${stats.failed || 0}`,
|
|
622
|
+
`hookarmor_events_total{status="pending"} ${stats.pending || 0}`,
|
|
623
|
+
`hookarmor_events_total{status="total"} ${stats.total || 0}`,
|
|
624
|
+
'',
|
|
625
|
+
'# HELP hookarmor_endpoints_total Total number of configured endpoints',
|
|
626
|
+
'# TYPE hookarmor_endpoints_total gauge',
|
|
627
|
+
`hookarmor_endpoints_total ${endpoints.length}`,
|
|
628
|
+
'',
|
|
629
|
+
'# HELP hookarmor_endpoints_unverified_total Number of endpoints without a cryptographic signing secret',
|
|
630
|
+
'# TYPE hookarmor_endpoints_unverified_total gauge',
|
|
631
|
+
`hookarmor_endpoints_unverified_total ${unverifiedCount}`,
|
|
632
|
+
'',
|
|
633
|
+
'# HELP hookarmor_delivery_latency_ms_avg Average delivery latency in milliseconds',
|
|
634
|
+
'# TYPE hookarmor_delivery_latency_ms_avg gauge',
|
|
635
|
+
`hookarmor_delivery_latency_ms_avg ${stats.avgLatencyMs || 0}`
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
639
|
+
res.end(lines.join('\n') + '\n');
|
|
640
|
+
} catch (err) {
|
|
641
|
+
res.status(500).setHeader('Content-Type', 'text/plain').end(`# Error collecting metrics: ${err.message}\n`);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
546
645
|
// SEO & Web Crawler Discovery
|
|
547
646
|
app.get('/robots.txt', (req, res) => {
|
|
548
647
|
res.type('text/plain');
|
|
@@ -587,7 +686,7 @@ function createServer(options = {}) {
|
|
|
587
686
|
res.redirect('/blog');
|
|
588
687
|
});
|
|
589
688
|
|
|
590
|
-
return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF };
|
|
689
|
+
return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF, instanceId };
|
|
591
690
|
}
|
|
592
691
|
|
|
593
692
|
module.exports = { createServer };
|
package/src/storage.js
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
const Database = require('better-sqlite3');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
|
|
5
6
|
// SQLite datetime() format (UTC, "YYYY-MM-DD HH:MM:SS") so stored times compare correctly with datetime('now')
|
|
6
7
|
function toSqliteTime(date) {
|
|
7
8
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
function deriveKey(keyInput) {
|
|
12
|
+
if (!keyInput) return null;
|
|
13
|
+
if (Buffer.isBuffer(keyInput) && keyInput.length === 32) return keyInput;
|
|
14
|
+
return crypto.createHash('sha256').update(String(keyInput)).digest();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function encryptSecret(plaintext, key) {
|
|
18
|
+
if (!plaintext || typeof plaintext !== 'string') return plaintext || '';
|
|
19
|
+
if (!key) return plaintext;
|
|
20
|
+
const iv = crypto.randomBytes(12);
|
|
21
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
22
|
+
const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]);
|
|
23
|
+
const tag = cipher.getAuthTag();
|
|
24
|
+
return `enc:v1:${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decryptSecret(stored, key) {
|
|
28
|
+
if (!stored || typeof stored !== 'string') return stored || '';
|
|
29
|
+
if (!stored.startsWith('enc:v1:')) {
|
|
30
|
+
return stored; // Plaintext legacy or fallback
|
|
31
|
+
}
|
|
32
|
+
if (!key) {
|
|
33
|
+
throw new Error('Endpoint secret is encrypted with AES-256-GCM, but HOOKARMOR_ENCRYPTION_KEY is not configured');
|
|
34
|
+
}
|
|
35
|
+
const parts = stored.split(':');
|
|
36
|
+
if (parts.length !== 5) {
|
|
37
|
+
throw new Error('Malformed encrypted secret format in database');
|
|
38
|
+
}
|
|
39
|
+
const iv = Buffer.from(parts[2], 'hex');
|
|
40
|
+
const tag = Buffer.from(parts[3], 'hex');
|
|
41
|
+
const ciphertext = Buffer.from(parts[4], 'hex');
|
|
42
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
43
|
+
decipher.setAuthTag(tag);
|
|
44
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
45
|
+
return decrypted.toString('utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
10
48
|
class Storage {
|
|
11
|
-
constructor(dbPath) {
|
|
49
|
+
constructor(dbPath, options = {}) {
|
|
12
50
|
if (!dbPath) {
|
|
13
51
|
const dataDir = process.env.HOOKARMOR_DATA_DIR || path.join(process.cwd(), 'data');
|
|
14
52
|
if (!fs.existsSync(dataDir)) {
|
|
@@ -16,6 +54,16 @@ class Storage {
|
|
|
16
54
|
}
|
|
17
55
|
dbPath = path.join(dataDir, 'hookarmor.db');
|
|
18
56
|
}
|
|
57
|
+
const envKey = process.env.HOOKARMOR_ENCRYPTION_KEY;
|
|
58
|
+
this.encryptionKey = options.encryptionKey !== undefined
|
|
59
|
+
? deriveKey(options.encryptionKey)
|
|
60
|
+
: deriveKey(envKey);
|
|
61
|
+
|
|
62
|
+
if (!this.encryptionKey && !Storage._warnedNoKey && process.env.NODE_ENV !== 'test') {
|
|
63
|
+
console.warn('[Security Warning] HOOKARMOR_ENCRYPTION_KEY is not set. Endpoint secrets are stored in plaintext. Set a 32+ character key to enable AES-256-GCM at-rest encryption.');
|
|
64
|
+
Storage._warnedNoKey = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
19
67
|
this.db = new Database(dbPath);
|
|
20
68
|
this.init();
|
|
21
69
|
}
|
|
@@ -54,6 +102,8 @@ class Storage {
|
|
|
54
102
|
last_error TEXT,
|
|
55
103
|
last_latency_ms INTEGER,
|
|
56
104
|
next_retry_at TEXT,
|
|
105
|
+
claimed_by TEXT,
|
|
106
|
+
claimed_at TEXT,
|
|
57
107
|
created_at TEXT DEFAULT (datetime('now')),
|
|
58
108
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
59
109
|
FOREIGN KEY (endpoint_id) REFERENCES endpoints(id)
|
|
@@ -70,6 +120,14 @@ class Storage {
|
|
|
70
120
|
FOREIGN KEY (event_id) REFERENCES events(id)
|
|
71
121
|
);
|
|
72
122
|
|
|
123
|
+
CREATE TABLE IF NOT EXISTS instances (
|
|
124
|
+
id TEXT PRIMARY KEY,
|
|
125
|
+
hostname TEXT,
|
|
126
|
+
pid INTEGER,
|
|
127
|
+
started_at TEXT DEFAULT (datetime('now')),
|
|
128
|
+
last_heartbeat TEXT DEFAULT (datetime('now'))
|
|
129
|
+
);
|
|
130
|
+
|
|
73
131
|
CREATE TABLE IF NOT EXISTS waitlist (
|
|
74
132
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
133
|
email TEXT UNIQUE NOT NULL,
|
|
@@ -85,11 +143,24 @@ class Storage {
|
|
|
85
143
|
CREATE INDEX IF NOT EXISTS idx_attempts_event ON delivery_attempts(event_id);
|
|
86
144
|
`);
|
|
87
145
|
|
|
88
|
-
//
|
|
146
|
+
// Migrations for existing databases
|
|
89
147
|
const eventColumns = this.db.prepare('PRAGMA table_info(events)').all().map((c) => c.name);
|
|
90
148
|
if (!eventColumns.includes('verified')) {
|
|
91
149
|
this.db.exec('ALTER TABLE events ADD COLUMN verified INTEGER DEFAULT 0');
|
|
92
150
|
}
|
|
151
|
+
if (!eventColumns.includes('claimed_by')) {
|
|
152
|
+
this.db.exec('ALTER TABLE events ADD COLUMN claimed_by TEXT');
|
|
153
|
+
}
|
|
154
|
+
if (!eventColumns.includes('claimed_at')) {
|
|
155
|
+
this.db.exec('ALTER TABLE events ADD COLUMN claimed_at TEXT');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
this.db.exec(`
|
|
160
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idem_unique
|
|
161
|
+
ON events(endpoint_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
162
|
+
`);
|
|
163
|
+
} catch (_) {}
|
|
93
164
|
}
|
|
94
165
|
|
|
95
166
|
// Upsert. Omitting secret or alertWebhookUrl (undefined) keeps the stored value; '' clears it.
|
|
@@ -97,6 +168,7 @@ class Storage {
|
|
|
97
168
|
const existing = this.getEndpoint(id);
|
|
98
169
|
const finalSecret = secret !== undefined ? secret : (existing ? existing.secret : '');
|
|
99
170
|
const finalAlert = alertWebhookUrl !== undefined ? alertWebhookUrl : (existing ? existing.alert_webhook_url : '');
|
|
171
|
+
const encryptedSecret = encryptSecret(finalSecret, this.encryptionKey);
|
|
100
172
|
this.db.prepare(`
|
|
101
173
|
INSERT INTO endpoints (id, name, target_url, secret, alert_webhook_url, auto_retry, max_retries, concurrency_limit)
|
|
102
174
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -108,21 +180,32 @@ class Storage {
|
|
|
108
180
|
auto_retry = excluded.auto_retry,
|
|
109
181
|
max_retries = excluded.max_retries,
|
|
110
182
|
concurrency_limit = excluded.concurrency_limit
|
|
111
|
-
`).run(id, name, targetUrl,
|
|
183
|
+
`).run(id, name, targetUrl, encryptedSecret || '', finalAlert || '', autoRetry ? 1 : 0, maxRetries, concurrencyLimit || 5);
|
|
112
184
|
return this.getEndpoint(id);
|
|
113
185
|
}
|
|
114
186
|
|
|
115
187
|
getEndpoint(id) {
|
|
116
|
-
|
|
188
|
+
const row = this.db.prepare('SELECT * FROM endpoints WHERE id = ?').get(id);
|
|
189
|
+
if (!row) return null;
|
|
190
|
+
return { ...row, secret: decryptSecret(row.secret, this.encryptionKey) };
|
|
117
191
|
}
|
|
118
192
|
|
|
119
193
|
listEndpoints() {
|
|
120
|
-
|
|
194
|
+
const rows = this.db.prepare('SELECT * FROM endpoints ORDER BY created_at DESC').all();
|
|
195
|
+
return rows.map((r) => ({ ...r, secret: decryptSecret(r.secret, this.encryptionKey) }));
|
|
121
196
|
}
|
|
122
197
|
|
|
123
198
|
deleteEndpoint(id) {
|
|
124
|
-
const
|
|
125
|
-
|
|
199
|
+
const deleteTx = this.db.transaction((epId) => {
|
|
200
|
+
this.db.prepare(`
|
|
201
|
+
DELETE FROM delivery_attempts WHERE event_id IN (
|
|
202
|
+
SELECT id FROM events WHERE endpoint_id = ?
|
|
203
|
+
)
|
|
204
|
+
`).run(epId);
|
|
205
|
+
this.db.prepare('DELETE FROM events WHERE endpoint_id = ?').run(epId);
|
|
206
|
+
return this.db.prepare('DELETE FROM endpoints WHERE id = ?').run(epId).changes > 0;
|
|
207
|
+
});
|
|
208
|
+
return deleteTx(id);
|
|
126
209
|
}
|
|
127
210
|
|
|
128
211
|
// Any stored copy counts: once HookArmor has custody, a provider re-send is redundant
|
|
@@ -133,24 +216,33 @@ class Storage {
|
|
|
133
216
|
return { ...row, headers: JSON.parse(row.headers) };
|
|
134
217
|
}
|
|
135
218
|
|
|
136
|
-
saveEvent({ id, endpointId, idempotencyKey = null, provider, eventType, headers, rawBody, status = 'pending', verified = false }) {
|
|
219
|
+
saveEvent({ id, endpointId, idempotencyKey = null, provider, eventType, headers, rawBody, status = 'pending', claimedBy = null, verified = false }) {
|
|
137
220
|
const rawBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8');
|
|
138
221
|
const stmt = this.db.prepare(`
|
|
139
|
-
INSERT INTO events (id, endpoint_id, idempotency_key, provider, event_type, headers, raw_body, status, attempts, verified, created_at, updated_at)
|
|
140
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, datetime('now'), datetime('now'))
|
|
222
|
+
INSERT INTO events (id, endpoint_id, idempotency_key, provider, event_type, headers, raw_body, status, attempts, claimed_by, claimed_at, verified, created_at, updated_at)
|
|
223
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ${claimedBy ? "datetime('now')" : 'NULL'}, ?, datetime('now'), datetime('now'))
|
|
141
224
|
`);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
225
|
+
try {
|
|
226
|
+
stmt.run(
|
|
227
|
+
id,
|
|
228
|
+
endpointId,
|
|
229
|
+
idempotencyKey,
|
|
230
|
+
provider,
|
|
231
|
+
eventType,
|
|
232
|
+
JSON.stringify(headers),
|
|
233
|
+
rawBuffer,
|
|
234
|
+
status,
|
|
235
|
+
claimedBy,
|
|
236
|
+
verified ? 1 : 0
|
|
237
|
+
);
|
|
238
|
+
return this.getEvent(id);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (idempotencyKey && (err.code === 'SQLITE_CONSTRAINT_UNIQUE' || (err.message && err.message.includes('UNIQUE constraint failed')))) {
|
|
241
|
+
const existing = this.findEventByIdempotencyKey(endpointId, idempotencyKey);
|
|
242
|
+
if (existing) return existing;
|
|
243
|
+
}
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
154
246
|
}
|
|
155
247
|
|
|
156
248
|
getPendingCount() {
|
|
@@ -290,35 +382,77 @@ class Storage {
|
|
|
290
382
|
}));
|
|
291
383
|
}
|
|
292
384
|
|
|
293
|
-
|
|
385
|
+
registerInstance({ id, hostname, pid }) {
|
|
386
|
+
if (!id) return;
|
|
387
|
+
this.db.prepare(`
|
|
388
|
+
INSERT INTO instances (id, hostname, pid, started_at, last_heartbeat)
|
|
389
|
+
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
|
390
|
+
ON CONFLICT(id) DO UPDATE SET last_heartbeat = datetime('now'), pid = excluded.pid, hostname = excluded.hostname
|
|
391
|
+
`).run(id, hostname || null, pid || null);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
heartbeatInstance(id) {
|
|
395
|
+
if (!id) return;
|
|
396
|
+
this.db.prepare(`
|
|
397
|
+
UPDATE instances SET last_heartbeat = datetime('now') WHERE id = ?
|
|
398
|
+
`).run(id);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
reapStaleInstances(timeoutSeconds = 300) {
|
|
402
|
+
return this.db.prepare(`
|
|
403
|
+
DELETE FROM instances WHERE datetime(last_heartbeat) < datetime('now', '-' || ? || ' seconds')
|
|
404
|
+
`).run(timeoutSeconds).changes;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
claimEventForRetry(eventId, instanceId = null) {
|
|
294
408
|
const info = this.db.prepare(`
|
|
295
409
|
UPDATE events
|
|
296
|
-
SET status = 'replaying',
|
|
410
|
+
SET status = 'replaying',
|
|
411
|
+
claimed_by = ?,
|
|
412
|
+
claimed_at = datetime('now'),
|
|
413
|
+
updated_at = datetime('now')
|
|
297
414
|
WHERE id = ? AND status IN ('failed', 'pending')
|
|
298
|
-
`).run(eventId);
|
|
415
|
+
`).run(instanceId, eventId);
|
|
299
416
|
return info.changes > 0;
|
|
300
417
|
}
|
|
301
418
|
|
|
302
419
|
// Manual replay may re-send delivered events, but never one that is already in flight
|
|
303
|
-
claimEventForManualReplay(eventId) {
|
|
420
|
+
claimEventForManualReplay(eventId, instanceId = null) {
|
|
304
421
|
const info = this.db.prepare(`
|
|
305
422
|
UPDATE events
|
|
306
|
-
SET status = 'replaying',
|
|
423
|
+
SET status = 'replaying',
|
|
424
|
+
claimed_by = ?,
|
|
425
|
+
claimed_at = datetime('now'),
|
|
426
|
+
updated_at = datetime('now')
|
|
307
427
|
WHERE id = ? AND status IN ('failed', 'pending', 'delivered')
|
|
308
|
-
`).run(eventId);
|
|
428
|
+
`).run(instanceId, eventId);
|
|
309
429
|
return info.changes > 0;
|
|
310
430
|
}
|
|
311
431
|
|
|
312
|
-
// Run
|
|
432
|
+
// Run at startup or worker tick:
|
|
433
|
+
// Reclaim events if:
|
|
434
|
+
// 1. claimed_by is NULL, OR
|
|
435
|
+
// 2. claimed_by is not active in instances table (last_heartbeat < 45 seconds ago), OR
|
|
436
|
+
// 3. claimed_at is older than 180 seconds (worker hung)
|
|
313
437
|
recoverInterruptedDeliveries() {
|
|
314
|
-
|
|
438
|
+
const sql = `
|
|
315
439
|
UPDATE events
|
|
316
440
|
SET status = 'failed',
|
|
317
441
|
next_retry_at = datetime('now'),
|
|
318
442
|
last_error = COALESCE(last_error, 'Delivery interrupted by restart'),
|
|
443
|
+
claimed_by = NULL,
|
|
444
|
+
claimed_at = NULL,
|
|
319
445
|
updated_at = datetime('now')
|
|
320
446
|
WHERE status = 'replaying'
|
|
321
|
-
|
|
447
|
+
AND (
|
|
448
|
+
claimed_by IS NULL
|
|
449
|
+
OR claimed_by NOT IN (
|
|
450
|
+
SELECT id FROM instances WHERE datetime(last_heartbeat) >= datetime('now', '-45 seconds')
|
|
451
|
+
)
|
|
452
|
+
OR (claimed_at IS NOT NULL AND datetime(claimed_at) < datetime('now', '-180 seconds'))
|
|
453
|
+
)
|
|
454
|
+
`;
|
|
455
|
+
return this.db.prepare(sql).run().changes;
|
|
322
456
|
}
|
|
323
457
|
|
|
324
458
|
// Delete delivered events (and their attempt logs) older than `days`. Failed events are kept.
|
|
@@ -351,5 +485,8 @@ class Storage {
|
|
|
351
485
|
}
|
|
352
486
|
|
|
353
487
|
Storage.toSqliteTime = toSqliteTime;
|
|
488
|
+
Storage.deriveKey = deriveKey;
|
|
489
|
+
Storage.encryptSecret = encryptSecret;
|
|
490
|
+
Storage.decryptSecret = decryptSecret;
|
|
354
491
|
|
|
355
492
|
module.exports = Storage;
|
package/src/worker.js
CHANGED
|
@@ -9,6 +9,7 @@ class RetryWorker extends EventEmitter {
|
|
|
9
9
|
this.retentionDays = options.retentionDays || 0;
|
|
10
10
|
this.pruneIntervalMs = options.pruneIntervalMs || 60 * 60 * 1000;
|
|
11
11
|
this.lastPruneAt = 0;
|
|
12
|
+
this.instanceId = options.instanceId || null;
|
|
12
13
|
this.timer = null;
|
|
13
14
|
this.isProcessing = false;
|
|
14
15
|
}
|
|
@@ -41,13 +42,19 @@ class RetryWorker extends EventEmitter {
|
|
|
41
42
|
this.isProcessing = true;
|
|
42
43
|
|
|
43
44
|
try {
|
|
45
|
+
if (this.instanceId) {
|
|
46
|
+
this.storage.heartbeatInstance(this.instanceId);
|
|
47
|
+
}
|
|
48
|
+
this.storage.recoverInterruptedDeliveries(this.instanceId);
|
|
49
|
+
this.storage.reapStaleInstances(300);
|
|
50
|
+
|
|
44
51
|
this.prune();
|
|
45
52
|
|
|
46
53
|
const eventsDue = this.storage.getEventsDueForRetry(25);
|
|
47
54
|
if (!eventsDue || eventsDue.length === 0) return;
|
|
48
55
|
|
|
49
56
|
for (const event of eventsDue) {
|
|
50
|
-
const claimed = this.storage.claimEventForRetry(event.id);
|
|
57
|
+
const claimed = this.storage.claimEventForRetry(event.id, this.instanceId);
|
|
51
58
|
if (!claimed) continue; // Another process or manual replay already claimed it
|
|
52
59
|
|
|
53
60
|
const endpoint = this.storage.getEndpoint(event.endpoint_id);
|