hookarmor 1.1.1 → 1.2.1
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 +45 -21
- package/bin/hookarmor.js +43 -5
- package/package.json +1 -1
- package/src/dispatcher.js +50 -2
- package/src/public/index.html +5 -2
- package/src/public/landing.html +25 -8
- package/src/server.js +134 -7
- package/src/storage.js +217 -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
|
```
|
|
@@ -211,7 +211,7 @@ HookArmor supports two forwarding architectures depending on your team's securit
|
|
|
211
211
|
|
|
212
212
|
## ⚠️ Production Durability: The Early 200 OK Tradeoff
|
|
213
213
|
|
|
214
|
-
HookArmor returns an immediate `200 OK` to Stripe and Shopify
|
|
214
|
+
HookArmor returns an immediate `200 OK` to Stripe and Shopify (sub-10ms when idle; measured p50 64ms, p90 153ms under concurrency 50 with `synchronous=FULL` fsync commits, sustaining ~360 events/s) to protect your ingress latency and prevent providers from marking your endpoint failed during downstream outages.
|
|
215
215
|
|
|
216
216
|
**What this means for production operators:**
|
|
217
217
|
* Once HookArmor returns `200 OK`, Stripe considers the webhook delivered and **halts its external retry backup**.
|
|
@@ -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 with immediate fsync commits, 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`. When `HOOKARMOR_API_KEY` is configured, requests must supply the key via `Authorization: Bearer <key>` or `x-api-key`. Scrape this endpoint into your existing Prometheus or VictoriaMetrics instance to monitor webhook health:
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
# Scraping: http://localhost:4000/metrics (Pass Authorization: Bearer <key> if enabled)
|
|
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,37 @@ function startServerOrExit() {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function setupGracefulShutdown(server, dispatcher, storage, instanceId) {
|
|
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
|
+
const clean = await dispatcher.drain(15000);
|
|
34
|
+
if (!clean) {
|
|
35
|
+
console.warn(chalk.yellow(`⚠️ Drain timed out with ${dispatcher.pendingDeliveries()} deliveries still pending.`));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (storage) {
|
|
39
|
+
try {
|
|
40
|
+
if (instanceId) {
|
|
41
|
+
storage.releaseClaimsOwnedBy(instanceId);
|
|
42
|
+
storage.deregisterInstance(instanceId);
|
|
43
|
+
}
|
|
44
|
+
if (storage.db) {
|
|
45
|
+
storage.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
46
|
+
}
|
|
47
|
+
} catch (_) {}
|
|
48
|
+
}
|
|
49
|
+
console.log(chalk.green('✔ Shutdown complete.'));
|
|
50
|
+
process.exit(0);
|
|
51
|
+
};
|
|
52
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
53
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
54
|
+
}
|
|
55
|
+
|
|
25
56
|
function apiHeaders(apiKey) {
|
|
26
57
|
const headers = { 'Content-Type': 'application/json' };
|
|
27
58
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
@@ -45,7 +76,8 @@ program
|
|
|
45
76
|
.action((options) => {
|
|
46
77
|
const port = parseInt(options.port, 10);
|
|
47
78
|
const host = options.host;
|
|
48
|
-
const { server, storage, mockEnabled } = startServerOrExit();
|
|
79
|
+
const { server, storage, dispatcher, mockEnabled, instanceId } = startServerOrExit();
|
|
80
|
+
setupGracefulShutdown(server, dispatcher, storage, instanceId);
|
|
49
81
|
|
|
50
82
|
// Ensure a default mock endpoint exists for first-time onboarding (local simulation only)
|
|
51
83
|
if (mockEnabled && !storage.getEndpoint('demo-stripe')) {
|
|
@@ -78,11 +110,17 @@ program
|
|
|
78
110
|
.description('Tunnel incoming webhooks directly to a local or remote target')
|
|
79
111
|
.argument('<targetUrl>', 'Destination target URL (e.g. http://localhost:3000/api/webhook)')
|
|
80
112
|
.option('-p, --port <number>', 'Proxy port', '4000')
|
|
113
|
+
.option('-H, --host <host>', 'Host address to bind to', '127.0.0.1')
|
|
81
114
|
.option('-e, --endpoint <id>', 'Endpoint ID slug', 'local-dev')
|
|
82
115
|
.option('-s, --secret <secret>', 'Provider signing secret: verify on ingress and re-sign on delivery')
|
|
83
116
|
.action(async (targetUrl, options) => {
|
|
84
117
|
const port = parseInt(options.port, 10);
|
|
85
|
-
const
|
|
118
|
+
const host = options.host || '127.0.0.1';
|
|
119
|
+
if (host === '0.0.0.0') {
|
|
120
|
+
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.'));
|
|
121
|
+
}
|
|
122
|
+
const { server, storage, dispatcher, instanceId } = startServerOrExit();
|
|
123
|
+
setupGracefulShutdown(server, dispatcher, storage, instanceId);
|
|
86
124
|
|
|
87
125
|
storage.createEndpoint({
|
|
88
126
|
id: options.endpoint,
|
|
@@ -103,10 +141,10 @@ program
|
|
|
103
141
|
}
|
|
104
142
|
});
|
|
105
143
|
|
|
106
|
-
server.listen(port, () => {
|
|
144
|
+
server.listen(port, host, () => {
|
|
107
145
|
console.log(chalk.cyan.bold(`\n⚡ HookArmor Listen Active`));
|
|
108
|
-
console.log(`Forwarding: ${chalk.yellow(`http
|
|
109
|
-
console.log(`Dashboard: ${chalk.cyan(`http
|
|
146
|
+
console.log(`Forwarding: ${chalk.yellow(`http://${host}:${port}/in/${options.endpoint}`)} ➔ ${chalk.green(targetUrl)}`);
|
|
147
|
+
console.log(`Dashboard: ${chalk.cyan(`http://${host}:${port}/dashboard`)}\n`);
|
|
110
148
|
});
|
|
111
149
|
});
|
|
112
150
|
|
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,33 @@ 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
|
+
let queued = 0;
|
|
228
|
+
for (const q of this.waitQueues.values()) {
|
|
229
|
+
queued += q.length;
|
|
230
|
+
}
|
|
231
|
+
return this.activeDeliveries + queued;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async drain(timeoutMs = 15000) {
|
|
235
|
+
const start = Date.now();
|
|
236
|
+
while (this.pendingDeliveries() > 0) {
|
|
237
|
+
if (Date.now() - start > timeoutMs) break;
|
|
238
|
+
await new Promise(r => setTimeout(r, 100));
|
|
239
|
+
}
|
|
240
|
+
return this.pendingDeliveries() === 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
207
243
|
// Callers must have claimed the event (status 'replaying') before dispatching
|
|
208
244
|
async dispatch(event, endpoint, { replay = false } = {}) {
|
|
209
245
|
const limit = endpoint.concurrency_limit || this.defaultConcurrency;
|
|
210
246
|
await this.acquireSlot(endpoint.id, limit);
|
|
247
|
+
this.activeDeliveries++;
|
|
211
248
|
|
|
212
249
|
try {
|
|
213
250
|
return await this._executeDispatch(event, endpoint, replay);
|
|
214
251
|
} finally {
|
|
252
|
+
this.activeDeliveries = Math.max(0, this.activeDeliveries - 1);
|
|
215
253
|
this.releaseSlot(endpoint.id, limit);
|
|
216
254
|
}
|
|
217
255
|
}
|
|
@@ -222,9 +260,16 @@ class Dispatcher extends EventEmitter {
|
|
|
222
260
|
|
|
223
261
|
let headers = { ...event.headers };
|
|
224
262
|
|
|
225
|
-
// Never forward sender-supplied HookArmor headers
|
|
263
|
+
// Never forward sender-supplied HookArmor headers or spoofable routing headers
|
|
226
264
|
for (const name of Object.keys(headers)) {
|
|
227
|
-
|
|
265
|
+
const lower = name.toLowerCase();
|
|
266
|
+
if (
|
|
267
|
+
lower.startsWith('x-hookarmor-') ||
|
|
268
|
+
lower.startsWith('x-forwarded-') ||
|
|
269
|
+
STRIPPED_INBOUND_HEADERS.has(lower)
|
|
270
|
+
) {
|
|
271
|
+
delete headers[name];
|
|
272
|
+
}
|
|
228
273
|
}
|
|
229
274
|
|
|
230
275
|
// Preserve original raw signatures before re-signing for audit and forensic trace
|
|
@@ -276,6 +321,9 @@ class Dispatcher extends EventEmitter {
|
|
|
276
321
|
headers['x-hookarmor-original-timestamp'] = originalTimestamp;
|
|
277
322
|
const isReplay = replay || event.attempts > 0;
|
|
278
323
|
headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
|
|
324
|
+
if (!event.verified) {
|
|
325
|
+
headers['x-hookarmor-unverified'] = 'true';
|
|
326
|
+
}
|
|
279
327
|
|
|
280
328
|
return new Promise((resolve) => {
|
|
281
329
|
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/public/landing.html
CHANGED
|
@@ -486,25 +486,42 @@
|
|
|
486
486
|
<li>✓ Web Dashboard & Inspector</li>
|
|
487
487
|
<li>✓ Terminal CLI runner</li>
|
|
488
488
|
<li>✓ Local Dev Tunneling</li>
|
|
489
|
-
<li>✓
|
|
489
|
+
<li>✓ Unlimited Self-Hosted Events</li>
|
|
490
|
+
<li>✓ MIT Licensed & Community Support</li>
|
|
490
491
|
</ul>
|
|
491
492
|
<a href="https://github.com/pkdoddamani/hookarmor" class="btn btn-outline" style="margin-top: auto;" target="_blank">Download on GitHub</a>
|
|
492
493
|
</div>
|
|
493
494
|
|
|
495
|
+
<div class="pricing-card">
|
|
496
|
+
<h3>Developer Cloud</h3>
|
|
497
|
+
<div class="price">$0 <span>/ month</span></div>
|
|
498
|
+
<p style="color: var(--text-muted); font-size: 0.85rem;">Zero-setup managed buffer for side projects and prototypes.</p>
|
|
499
|
+
<ul class="feature-list">
|
|
500
|
+
<li>✓ <strong>10,000 events / month</strong></li>
|
|
501
|
+
<li>✓ <strong>7-day retention</strong> in DLQ</li>
|
|
502
|
+
<li>✓ 2 Webhook Endpoints</li>
|
|
503
|
+
<li>✓ Instant Discord & Slack Alerts</li>
|
|
504
|
+
<li>✓ Automatic Exponential Retries</li>
|
|
505
|
+
<li>✓ 1-Click UI & API Replay</li>
|
|
506
|
+
</ul>
|
|
507
|
+
<a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-outline" style="margin-top: auto;">Join Free Waitlist</a>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
494
510
|
<div class="pricing-card featured">
|
|
495
|
-
<div class="featured-badge">
|
|
511
|
+
<div class="featured-badge">FOUNDER LOCK — $19/MO</div>
|
|
496
512
|
<h3>Starter Cloud</h3>
|
|
497
|
-
<div class="price">$
|
|
513
|
+
<div class="price">$19 <span>/ month</span></div>
|
|
498
514
|
<p style="color: var(--text-muted); font-size: 0.85rem;">For growing B2B SaaS apps and indie founders.</p>
|
|
499
515
|
<ul class="feature-list">
|
|
500
|
-
<li>✓ <strong>
|
|
516
|
+
<li>✓ <strong>100,000 events / month</strong></li>
|
|
501
517
|
<li>✓ <strong>30-day retention</strong> in DLQ</li>
|
|
502
|
-
<li>✓
|
|
518
|
+
<li>✓ 10 Webhook Endpoints</li>
|
|
503
519
|
<li>✓ Instant Discord & Slack Alerts</li>
|
|
504
520
|
<li>✓ Automatic Exponential Retries</li>
|
|
505
521
|
<li>✓ 1-Click UI & API Replay</li>
|
|
522
|
+
<li>✓ <strong>Rate locked for life</strong> (First 50)</li>
|
|
506
523
|
</ul>
|
|
507
|
-
<a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-primary" style="margin-top: auto;">
|
|
524
|
+
<a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-primary" style="margin-top: auto;">Lock In $19/mo</a>
|
|
508
525
|
</div>
|
|
509
526
|
|
|
510
527
|
<div class="pricing-card">
|
|
@@ -512,12 +529,12 @@
|
|
|
512
529
|
<div class="price">$79 <span>/ month</span></div>
|
|
513
530
|
<p style="color: var(--text-muted); font-size: 0.85rem;">For high-volume e-commerce and scale-ups.</p>
|
|
514
531
|
<ul class="feature-list">
|
|
515
|
-
<li>✓ <strong>
|
|
532
|
+
<li>✓ <strong>500,000 events / month</strong></li>
|
|
516
533
|
<li>✓ <strong>90-day retention</strong> in DLQ</li>
|
|
517
534
|
<li>✓ Unlimited Endpoints</li>
|
|
518
535
|
<li>✓ Per-Endpoint Concurrency Limiting</li>
|
|
519
|
-
<li>✓ Team Workspaces (Beta Roadmap)</li>
|
|
520
536
|
<li>✓ High-Concurrency Queue Worker</li>
|
|
537
|
+
<li>✓ Priority Outbound Bandwidth</li>
|
|
521
538
|
</ul>
|
|
522
539
|
<a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-outline" style="margin-top: auto;">Upgrade to Scale</a>
|
|
523
540
|
</div>
|
package/src/server.js
CHANGED
|
@@ -90,15 +90,56 @@ 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
|
+
// Fail-fast verification: ensure all endpoint secrets can be read with the active encryption key
|
|
131
|
+
storage.assertSecretsReadable(Boolean(process.env.HOOKARMOR_ALLOW_UNREADABLE_SECRETS === 'true'));
|
|
132
|
+
|
|
133
|
+
// Security audit check: warn about endpoints without secrets on startup
|
|
134
|
+
try {
|
|
135
|
+
const initialEndpoints = storage.listEndpoints();
|
|
136
|
+
for (const ep of initialEndpoints) {
|
|
137
|
+
if (!ep.secret) {
|
|
138
|
+
console.warn(`[Security Warning] Endpoint '${ep.id}' has no signing secret. Incoming webhooks cannot be cryptographically verified against spoofing!`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (_) {}
|
|
142
|
+
|
|
102
143
|
const dispatcher = new Dispatcher(storage, {
|
|
103
144
|
defaultConcurrency: options.defaultConcurrency || 5,
|
|
104
145
|
defaultTimeoutMs: options.defaultTimeoutMs || 25000,
|
|
@@ -108,7 +149,8 @@ function createServer(options = {}) {
|
|
|
108
149
|
|
|
109
150
|
const worker = new RetryWorker(storage, dispatcher, {
|
|
110
151
|
intervalMs: options.retryIntervalMs || 5000,
|
|
111
|
-
retentionDays
|
|
152
|
+
retentionDays,
|
|
153
|
+
instanceId
|
|
112
154
|
});
|
|
113
155
|
if (options.autoStartWorker !== false) {
|
|
114
156
|
worker.start();
|
|
@@ -147,7 +189,7 @@ function createServer(options = {}) {
|
|
|
147
189
|
socket.isAuthed = !apiKey || isDemoMode;
|
|
148
190
|
let authTimer = null;
|
|
149
191
|
if (!socket.isAuthed) {
|
|
150
|
-
authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); },
|
|
192
|
+
authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 5000);
|
|
151
193
|
if (authTimer.unref) authTimer.unref();
|
|
152
194
|
}
|
|
153
195
|
// Browsers cannot set headers on WebSocket upgrades, so the key arrives as the first message
|
|
@@ -271,6 +313,9 @@ function createServer(options = {}) {
|
|
|
271
313
|
});
|
|
272
314
|
}
|
|
273
315
|
verified = true;
|
|
316
|
+
} else {
|
|
317
|
+
console.warn(`[Ingress Warning] Ingested webhook for endpoint '${endpoint.id}' without signature verification (no secret configured).`);
|
|
318
|
+
headers['x-hookarmor-unverified'] = 'true';
|
|
274
319
|
}
|
|
275
320
|
|
|
276
321
|
let savedEvent;
|
|
@@ -300,6 +345,7 @@ function createServer(options = {}) {
|
|
|
300
345
|
headers,
|
|
301
346
|
rawBody: rawBodyBuffer,
|
|
302
347
|
status: 'replaying',
|
|
348
|
+
claimedBy: instanceId,
|
|
303
349
|
verified
|
|
304
350
|
});
|
|
305
351
|
} catch (err) {
|
|
@@ -522,13 +568,29 @@ function createServer(options = {}) {
|
|
|
522
568
|
responseBody: { status: 'mock_processed' }
|
|
523
569
|
};
|
|
524
570
|
|
|
571
|
+
const mockAuthMiddleware = (req, res, next) => {
|
|
572
|
+
if (apiKey) {
|
|
573
|
+
const header = req.headers['authorization'] || '';
|
|
574
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : (req.headers['x-api-key'] || '');
|
|
575
|
+
if (!tokenMatches(token)) {
|
|
576
|
+
return res.status(401).json({ error: 'Unauthorized: valid API key required for /mock/config' });
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
const host = hostnameOf(req.headers['host']);
|
|
580
|
+
if (!LOCAL_HOSTNAMES.has(host) && !allowNoAuth) {
|
|
581
|
+
return res.status(403).json({ error: 'Forbidden: loopback access only' });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
next();
|
|
585
|
+
};
|
|
586
|
+
|
|
525
587
|
app.post('/mock/target', (req, res) => {
|
|
526
588
|
setTimeout(() => {
|
|
527
589
|
res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
|
|
528
590
|
}, mockTargetBehavior.delayMs);
|
|
529
591
|
});
|
|
530
592
|
|
|
531
|
-
app.post('/mock/config', (req, res) => {
|
|
593
|
+
app.post('/mock/config', mockAuthMiddleware, (req, res) => {
|
|
532
594
|
const body = req.body || {};
|
|
533
595
|
mockTargetBehavior = {
|
|
534
596
|
statusCode: clampInt(body.statusCode, mockTargetBehavior.statusCode, 100, 599),
|
|
@@ -538,11 +600,76 @@ function createServer(options = {}) {
|
|
|
538
600
|
res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
|
|
539
601
|
});
|
|
540
602
|
|
|
541
|
-
app.get('/mock/config', (req, res) => {
|
|
603
|
+
app.get('/mock/config', mockAuthMiddleware, (req, res) => {
|
|
542
604
|
res.json(mockTargetBehavior);
|
|
543
605
|
});
|
|
544
606
|
}
|
|
545
607
|
|
|
608
|
+
// Prometheus Metrics Exposition Endpoint (Requires API key if configured; otherwise loopback only)
|
|
609
|
+
app.get('/metrics', (req, res) => {
|
|
610
|
+
if (apiKey) {
|
|
611
|
+
const authHeader = req.headers['authorization'] || '';
|
|
612
|
+
const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
|
|
613
|
+
if (!tokenMatches(token)) {
|
|
614
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
615
|
+
return res.status(401).end('# Unauthorized: valid API key required for /metrics\n');
|
|
616
|
+
}
|
|
617
|
+
} else if (!isDemoMode) {
|
|
618
|
+
const remote = req.socket && req.socket.remoteAddress;
|
|
619
|
+
const isLoopbackRemote = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
620
|
+
if (!isLoopbackRemote || !LOCAL_HOSTNAMES.has(hostnameOf(req.headers.host))) {
|
|
621
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
622
|
+
return res.status(403).end('# Forbidden: loopback only without API key\n');
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
const stats = storage.getStats();
|
|
628
|
+
const endpoints = storage.listEndpoints();
|
|
629
|
+
const unverifiedCount = endpoints.filter((e) => !e.secret).length;
|
|
630
|
+
const uptime = Math.floor(process.uptime());
|
|
631
|
+
|
|
632
|
+
const lines = [
|
|
633
|
+
'# HELP hookarmor_uptime_seconds Total process uptime in seconds',
|
|
634
|
+
'# TYPE hookarmor_uptime_seconds counter',
|
|
635
|
+
`hookarmor_uptime_seconds ${uptime}`,
|
|
636
|
+
'',
|
|
637
|
+
'# HELP hookarmor_events_total Total number of events by status',
|
|
638
|
+
'# TYPE hookarmor_events_total gauge',
|
|
639
|
+
`hookarmor_events_total{status="delivered"} ${stats.delivered || 0}`,
|
|
640
|
+
`hookarmor_events_total{status="failed"} ${stats.failed || 0}`,
|
|
641
|
+
`hookarmor_events_total{status="pending"} ${stats.pending || 0}`,
|
|
642
|
+
`hookarmor_events_total{status="total"} ${stats.total || 0}`,
|
|
643
|
+
'',
|
|
644
|
+
'# HELP hookarmor_endpoints_total Total number of configured endpoints',
|
|
645
|
+
'# TYPE hookarmor_endpoints_total gauge',
|
|
646
|
+
`hookarmor_endpoints_total ${endpoints.length}`,
|
|
647
|
+
'',
|
|
648
|
+
'# HELP hookarmor_endpoints_unverified_total Number of endpoints without a cryptographic signing secret',
|
|
649
|
+
'# TYPE hookarmor_endpoints_unverified_total gauge',
|
|
650
|
+
`hookarmor_endpoints_unverified_total ${unverifiedCount}`,
|
|
651
|
+
'',
|
|
652
|
+
'# HELP hookarmor_delivery_latency_ms_avg Average delivery latency in milliseconds',
|
|
653
|
+
'# TYPE hookarmor_delivery_latency_ms_avg gauge',
|
|
654
|
+
`hookarmor_delivery_latency_ms_avg ${stats.avgLatencyMs || 0}`
|
|
655
|
+
];
|
|
656
|
+
|
|
657
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
658
|
+
res.end(lines.join('\n') + '\n');
|
|
659
|
+
} catch (err) {
|
|
660
|
+
res.status(500).setHeader('Content-Type', 'text/plain').end(`# Error collecting metrics: ${err.message}\n`);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// Health and Version Check Endpoint
|
|
665
|
+
app.get('/healthz', (req, res) => {
|
|
666
|
+
res.json({
|
|
667
|
+
status: 'ok',
|
|
668
|
+
version: require('../package.json').version,
|
|
669
|
+
uptime: Math.floor(process.uptime())
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
546
673
|
// SEO & Web Crawler Discovery
|
|
547
674
|
app.get('/robots.txt', (req, res) => {
|
|
548
675
|
res.type('text/plain');
|
|
@@ -587,7 +714,7 @@ function createServer(options = {}) {
|
|
|
587
714
|
res.redirect('/blog');
|
|
588
715
|
});
|
|
589
716
|
|
|
590
|
-
return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF };
|
|
717
|
+
return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF, instanceId };
|
|
591
718
|
}
|
|
592
719
|
|
|
593
720
|
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,118 @@ 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
|
-
//
|
|
432
|
+
// Release claims owned by a terminating instance so a successor can process them immediately
|
|
433
|
+
releaseClaimsOwnedBy(instanceId) {
|
|
434
|
+
if (!instanceId) return 0;
|
|
435
|
+
const stmt = this.db.prepare(`
|
|
436
|
+
UPDATE events
|
|
437
|
+
SET status = 'pending',
|
|
438
|
+
claimed_by = NULL,
|
|
439
|
+
claimed_at = NULL,
|
|
440
|
+
updated_at = datetime('now')
|
|
441
|
+
WHERE status = 'replaying' AND claimed_by = ?
|
|
442
|
+
`);
|
|
443
|
+
return stmt.run(instanceId).changes;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Deregister an instance on clean shutdown
|
|
447
|
+
deregisterInstance(instanceId) {
|
|
448
|
+
if (!instanceId) return 0;
|
|
449
|
+
return this.db.prepare('DELETE FROM instances WHERE id = ?').run(instanceId).changes;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Fail-fast assertion: verify all configured endpoint secrets can be decrypted
|
|
453
|
+
assertSecretsReadable(allowUnreadable = false) {
|
|
454
|
+
const endpoints = this.db.prepare('SELECT id, secret FROM endpoints').all();
|
|
455
|
+
const unreadable = [];
|
|
456
|
+
for (const ep of endpoints) {
|
|
457
|
+
if (!ep.secret) continue;
|
|
458
|
+
try {
|
|
459
|
+
decryptSecret(ep.secret, this.encryptionKey);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
unreadable.push(ep.id);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (unreadable.length > 0 && !allowUnreadable) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Failed to decrypt signing secrets for endpoint(s): ${unreadable.join(', ')}. ` +
|
|
467
|
+
`HOOKARMOR_ENCRYPTION_KEY may be missing or invalid. ` +
|
|
468
|
+
`Set HOOKARMOR_ALLOW_UNREADABLE_SECRETS=true to start anyway.`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return unreadable.length === 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Run at startup or worker tick:
|
|
475
|
+
// Reclaim events if:
|
|
476
|
+
// 1. claimed_by is NULL, OR
|
|
477
|
+
// 2. claimed_by is not active in instances table (last_heartbeat < 45 seconds ago)
|
|
478
|
+
// NOTE: Claims held by live heartbeating instances are never reclaimed merely due to age (N1 fix).
|
|
313
479
|
recoverInterruptedDeliveries() {
|
|
314
|
-
|
|
480
|
+
const sql = `
|
|
315
481
|
UPDATE events
|
|
316
482
|
SET status = 'failed',
|
|
317
483
|
next_retry_at = datetime('now'),
|
|
318
484
|
last_error = COALESCE(last_error, 'Delivery interrupted by restart'),
|
|
485
|
+
claimed_by = NULL,
|
|
486
|
+
claimed_at = NULL,
|
|
319
487
|
updated_at = datetime('now')
|
|
320
488
|
WHERE status = 'replaying'
|
|
321
|
-
|
|
489
|
+
AND (
|
|
490
|
+
claimed_by IS NULL
|
|
491
|
+
OR claimed_by NOT IN (
|
|
492
|
+
SELECT id FROM instances WHERE datetime(last_heartbeat) >= datetime('now', '-45 seconds')
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
`;
|
|
496
|
+
return this.db.prepare(sql).run().changes;
|
|
322
497
|
}
|
|
323
498
|
|
|
324
499
|
// Delete delivered events (and their attempt logs) older than `days`. Failed events are kept.
|
|
@@ -348,8 +523,19 @@ class Storage {
|
|
|
348
523
|
listWaitlist() {
|
|
349
524
|
return this.db.prepare('SELECT * FROM waitlist ORDER BY created_at DESC').all();
|
|
350
525
|
}
|
|
526
|
+
|
|
527
|
+
close() {
|
|
528
|
+
if (this.db) {
|
|
529
|
+
try {
|
|
530
|
+
this.db.close();
|
|
531
|
+
} catch (_) {}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
351
534
|
}
|
|
352
535
|
|
|
353
536
|
Storage.toSqliteTime = toSqliteTime;
|
|
537
|
+
Storage.deriveKey = deriveKey;
|
|
538
|
+
Storage.encryptSecret = encryptSecret;
|
|
539
|
+
Storage.decryptSecret = decryptSecret;
|
|
354
540
|
|
|
355
541
|
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);
|