web-agent-bridge 3.16.0 → 3.20.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.ar.md +27 -8
- package/README.md +95 -0
- package/bin/wab-init.js +38 -0
- package/package.json +1 -1
- package/public/atp-semantics.html +216 -0
- package/public/benchmarks.html +151 -0
- package/public/dashboard.html +1 -0
- package/public/docs.html +113 -43
- package/public/index.html +142 -8
- package/public/key-rotation.html +184 -0
- package/public/llms.txt +54 -0
- package/public/notary.html +94 -0
- package/public/observatory.html +103 -0
- package/public/research.html +57 -0
- package/public/researchers.html +113 -0
- package/public/responsible-disclosure.html +294 -0
- package/public/robots.txt +17 -0
- package/public/security.html +157 -0
- package/public/threat-model.html +153 -0
- package/public/viral-coefficient.html +533 -0
- package/public/wab-dataset.html +501 -0
- package/public/wab-email.html +78 -0
- package/public/wab-lens.html +61 -0
- package/public/wab-p2p.html +96 -0
- package/public/wab-registry.html +481 -0
- package/public/wab-today.html +448 -0
- package/public/wab-uri.html +88 -0
- package/public/webhooks.html +181 -0
- package/script/ai-agent-bridge.js +24 -4
- package/server/index.js +1193 -827
- package/server/models/db.js +2 -1
- package/server/routes/admin-shieldlink.js +1 -1
- package/server/routes/admin-shieldqr.js +1 -1
- package/server/routes/admin-trust-monitor.js +1 -1
- package/server/routes/api-keys.js +2 -1
- package/server/routes/customer-shieldlink.js +1 -1
- package/server/routes/enterprise-mesh.js +2 -1
- package/server/routes/genius-bridge.js +256 -0
- package/server/routes/genius-gateway.js +137 -0
- package/server/routes/governance-saas.js +2 -1
- package/server/routes/notary.js +309 -0
- package/server/routes/observatory.js +109 -0
- package/server/routes/partners.js +2 -1
- package/server/routes/registry.js +352 -0
- package/server/routes/research.js +83 -0
- package/server/routes/ring4.js +2 -1
- package/server/routes/runtime.js +98 -25
- package/server/routes/security-researchers.js +161 -0
- package/server/routes/shieldqr.js +1 -1
- package/server/routes/traces.js +247 -0
- package/server/services/agent-tasks.js +9 -7
- package/server/services/email.js +50 -2
- package/server/services/marketplace.js +27 -8
- package/server/services/plans.js +1 -1
- package/server/services/shieldlink.js +1 -1
- package/server/services/ssl-ct-monitor.js +1 -1
- package/server/services/ssl-monitor.js +1 -1
- package/server/services/stripe.js +29 -4
- package/server/services/webhooks.js +61 -1
- package/server/utils/migrate.js +1 -1
- package/server/utils/safe-compare.js +26 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// WAB Notary — neutral third-party attestation service.
|
|
2
|
+
//
|
|
3
|
+
// Anyone can POST { host } and receive a server-signed JSON receipt stating
|
|
4
|
+
// what the notary observed at `https://<host>/.well-known/wab.json` at a
|
|
5
|
+
// given instant. Receipts are deterministic over (host, manifest bytes,
|
|
6
|
+
// observed_at minute) and cached so repeated calls are cheap.
|
|
7
|
+
//
|
|
8
|
+
// Receipts include:
|
|
9
|
+
// - status: "verified" | "enabled" | "missing" | "invalid"
|
|
10
|
+
// - manifest_sha256
|
|
11
|
+
// - signed: boolean (manifest carried an Ed25519 signature)
|
|
12
|
+
// - observed_at: ISO8601
|
|
13
|
+
// - notary: fingerprint of the server signing key
|
|
14
|
+
// - signature: Ed25519 signature over canonicalized payload
|
|
15
|
+
//
|
|
16
|
+
// The notary's signing key is generated on first use and persisted to
|
|
17
|
+
// `data/.notary-key.json`. The PUBLIC key is exposed at GET /api/notary/key
|
|
18
|
+
// and at /.well-known/wab-notary.json so clients can verify offline.
|
|
19
|
+
|
|
20
|
+
const express = require('express');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const wabCrypto = require('../services/wab-crypto');
|
|
25
|
+
const { safeFetch } = require('../utils/safe-fetch');
|
|
26
|
+
|
|
27
|
+
const router = express.Router();
|
|
28
|
+
const KEY_PATH = path.join(__dirname, '..', '..', 'data', '.notary-key.json'); // legacy single-key (kept for migration)
|
|
29
|
+
const KEYS_PATH = path.join(__dirname, '..', '..', 'data', '.notary-keys.json'); // key history (rotation)
|
|
30
|
+
const PEERS_PATH = path.join(__dirname, '..', '..', 'data', 'notary-peers.json'); // web-of-trust peer notaries
|
|
31
|
+
const ADMIN_TOKEN = process.env.WAB_NOTARY_ADMIN_TOKEN || '';
|
|
32
|
+
const { safeEqual } = require('../utils/safe-compare');
|
|
33
|
+
|
|
34
|
+
function safeReadJson(p, fallback) {
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return fallback; }
|
|
36
|
+
}
|
|
37
|
+
function safeWriteJson(p, value, mode) {
|
|
38
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
39
|
+
fs.writeFileSync(p, JSON.stringify(value, null, 2), mode ? { mode } : undefined);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadKeys() {
|
|
43
|
+
// Migrate legacy single-key file → new history file on first load.
|
|
44
|
+
let keys = safeReadJson(KEYS_PATH, null);
|
|
45
|
+
if (Array.isArray(keys) && keys.length) return keys;
|
|
46
|
+
const legacy = safeReadJson(KEY_PATH, null);
|
|
47
|
+
if (legacy && legacy.private_key && legacy.public_key) {
|
|
48
|
+
keys = [{
|
|
49
|
+
id: 'k1',
|
|
50
|
+
public_key: legacy.public_key,
|
|
51
|
+
private_key: legacy.private_key,
|
|
52
|
+
created_at: new Date().toISOString(),
|
|
53
|
+
retired_at: null
|
|
54
|
+
}];
|
|
55
|
+
} else {
|
|
56
|
+
const kp = wabCrypto.generateKeyPair();
|
|
57
|
+
keys = [{
|
|
58
|
+
id: 'k1',
|
|
59
|
+
public_key: kp.public_key,
|
|
60
|
+
private_key: kp.private_key,
|
|
61
|
+
created_at: new Date().toISOString(),
|
|
62
|
+
retired_at: null
|
|
63
|
+
}];
|
|
64
|
+
}
|
|
65
|
+
safeWriteJson(KEYS_PATH, keys, 0o600);
|
|
66
|
+
return keys;
|
|
67
|
+
}
|
|
68
|
+
function activeKey(keys) { return keys.find(k => !k.retired_at) || keys[0]; }
|
|
69
|
+
function keyById(keys, id) { return keys.find(k => k.id === id) || null; }
|
|
70
|
+
|
|
71
|
+
let KEYS = loadKeys();
|
|
72
|
+
function CURRENT() { return activeKey(KEYS); }
|
|
73
|
+
function FP_OF(pub) { return wabCrypto.fingerprint(pub); }
|
|
74
|
+
|
|
75
|
+
function canon(obj) {
|
|
76
|
+
// RFC 8785 -ish: stable key order, no whitespace.
|
|
77
|
+
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
78
|
+
if (Array.isArray(obj)) return '[' + obj.map(canon).join(',') + ']';
|
|
79
|
+
const keys = Object.keys(obj).sort();
|
|
80
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canon(obj[k])).join(',') + '}';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function signPayload(payload) {
|
|
84
|
+
const k = CURRENT();
|
|
85
|
+
const priv = wabCrypto.rawToPrivateKey(k.private_key);
|
|
86
|
+
const sig = crypto.sign(null, Buffer.from(canon(payload)), priv);
|
|
87
|
+
return sig.toString('base64');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const _cache = new Map();
|
|
91
|
+
const HOST_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
|
|
92
|
+
|
|
93
|
+
async function observe(host) {
|
|
94
|
+
const cached = _cache.get(host);
|
|
95
|
+
if (cached && cached.exp > Date.now()) return cached.attestation;
|
|
96
|
+
let status = 'missing', signed = false, manifest_sha256 = null, manifest_size = 0;
|
|
97
|
+
try {
|
|
98
|
+
// SSRF-safe: safeFetch resolves DNS and blocks private/loopback/link-local IPs,
|
|
99
|
+
// and re-validates on every redirect hop. This blocks nip.io-style hostnames
|
|
100
|
+
// that pass regex but resolve to internal addresses.
|
|
101
|
+
const r = await safeFetch(`https://${host}/.well-known/wab.json`, {}, { timeoutMs: 4500, maxBytes: 256 * 1024, requireHttps: true });
|
|
102
|
+
if (r.ok) {
|
|
103
|
+
const body = await r.text();
|
|
104
|
+
manifest_size = body.length;
|
|
105
|
+
manifest_sha256 = crypto.createHash('sha256').update(body).digest('hex');
|
|
106
|
+
let j = null;
|
|
107
|
+
try { j = JSON.parse(body); } catch (_) {}
|
|
108
|
+
if (j) {
|
|
109
|
+
signed = !!(j.signature || (j.trust && j.trust.signed));
|
|
110
|
+
status = signed ? 'verified' : 'enabled';
|
|
111
|
+
} else {
|
|
112
|
+
status = 'invalid';
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
status = 'missing';
|
|
116
|
+
}
|
|
117
|
+
} catch (_) { status = 'missing'; }
|
|
118
|
+
finally { /* safeFetch manages its own timer */ }
|
|
119
|
+
const observed_at = new Date().toISOString();
|
|
120
|
+
const k = CURRENT();
|
|
121
|
+
const payload = { host, status, signed, manifest_sha256, manifest_size, observed_at, notary: FP_OF(k.public_key), key_id: k.id, version: 1 };
|
|
122
|
+
const attestation = { ...payload, algorithm: 'ed25519', signature: signPayload(payload) };
|
|
123
|
+
_cache.set(host, { attestation, exp: Date.now() + 5 * 60 * 1000 });
|
|
124
|
+
return attestation;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
router.get('/key', (req, res) => {
|
|
128
|
+
const k = CURRENT();
|
|
129
|
+
res.json({
|
|
130
|
+
algorithm: 'ed25519',
|
|
131
|
+
key_id: k.id,
|
|
132
|
+
public_key: k.public_key,
|
|
133
|
+
fingerprint: FP_OF(k.public_key),
|
|
134
|
+
verify_hint: 'sig = ed25519_sign(canonicalize(payload), notary_private_key); canonicalize = sorted keys, no whitespace.'
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// All keys (active + retired). Clients keep this list to verify historical
|
|
139
|
+
// attestations after the notary rotates. Public — never exposes private bytes.
|
|
140
|
+
router.get('/keys', (req, res) => {
|
|
141
|
+
res.set('Cache-Control', 'public, max-age=300');
|
|
142
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
143
|
+
res.json({
|
|
144
|
+
algorithm: 'ed25519',
|
|
145
|
+
keys: KEYS.map(k => ({
|
|
146
|
+
key_id: k.id,
|
|
147
|
+
public_key: k.public_key,
|
|
148
|
+
fingerprint: FP_OF(k.public_key),
|
|
149
|
+
created_at: k.created_at,
|
|
150
|
+
retired_at: k.retired_at || null,
|
|
151
|
+
active: !k.retired_at
|
|
152
|
+
}))
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Admin: rotate to a fresh key. Gated by WAB_NOTARY_ADMIN_TOKEN. The previous
|
|
157
|
+
// active key is marked `retired_at = now` so it stays available for
|
|
158
|
+
// verification of past receipts but is no longer used for signing.
|
|
159
|
+
router.post('/admin/rotate', express.json({ limit: '1kb' }), (req, res) => {
|
|
160
|
+
if (!ADMIN_TOKEN || !safeEqual(req.get('x-admin-token'), ADMIN_TOKEN)) {
|
|
161
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
162
|
+
}
|
|
163
|
+
const now = new Date().toISOString();
|
|
164
|
+
for (const k of KEYS) if (!k.retired_at) k.retired_at = now;
|
|
165
|
+
const kp = wabCrypto.generateKeyPair();
|
|
166
|
+
const next = {
|
|
167
|
+
id: `k${KEYS.length + 1}`,
|
|
168
|
+
public_key: kp.public_key,
|
|
169
|
+
private_key: kp.private_key,
|
|
170
|
+
created_at: now,
|
|
171
|
+
retired_at: null
|
|
172
|
+
};
|
|
173
|
+
KEYS.push(next);
|
|
174
|
+
safeWriteJson(KEYS_PATH, KEYS, 0o600);
|
|
175
|
+
_cache.clear();
|
|
176
|
+
res.json({
|
|
177
|
+
rotated: true,
|
|
178
|
+
new_key_id: next.id,
|
|
179
|
+
new_fingerprint: FP_OF(next.public_key),
|
|
180
|
+
retired_count: KEYS.length - 1
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
router.post('/attest', express.json({ limit: '4kb' }), async (req, res) => {
|
|
185
|
+
const host = String(req.body && req.body.host || '').trim().toLowerCase()
|
|
186
|
+
.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
187
|
+
if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
|
|
188
|
+
try {
|
|
189
|
+
const att = await observe(host);
|
|
190
|
+
res.set('Cache-Control', 'public, max-age=60');
|
|
191
|
+
return res.json(att);
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return res.status(500).json({ error: 'attest_failed', detail: e.message });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
router.get('/attest/:host', async (req, res) => {
|
|
198
|
+
const host = String(req.params.host || '').trim().toLowerCase()
|
|
199
|
+
.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
200
|
+
if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
|
|
201
|
+
try {
|
|
202
|
+
const att = await observe(host);
|
|
203
|
+
res.set('Cache-Control', 'public, max-age=60');
|
|
204
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
205
|
+
return res.json(att);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return res.status(500).json({ error: 'attest_failed', detail: e.message });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
router.post('/verify', express.json({ limit: '16kb' }), (req, res) => {
|
|
212
|
+
const att = req.body && req.body.attestation;
|
|
213
|
+
if (!att || !att.signature) return res.status(400).json({ error: 'missing_attestation' });
|
|
214
|
+
try {
|
|
215
|
+
const { signature, algorithm, ...payload } = att;
|
|
216
|
+
// Look up the signing key by key_id (preferred) or by notary fingerprint
|
|
217
|
+
// (fallback for older receipts that didn't include key_id).
|
|
218
|
+
let k = null;
|
|
219
|
+
if (payload.key_id) k = keyById(KEYS, payload.key_id);
|
|
220
|
+
if (!k && payload.notary) k = KEYS.find(x => FP_OF(x.public_key) === payload.notary);
|
|
221
|
+
if (!k) return res.status(404).json({ valid: false, error: 'unknown_key_id', key_id: payload.key_id || null });
|
|
222
|
+
const pub = wabCrypto.rawToPublicKey(k.public_key);
|
|
223
|
+
const ok = crypto.verify(null, Buffer.from(canon(payload)), pub, Buffer.from(signature, 'base64'));
|
|
224
|
+
return res.json({ valid: ok, key_id: k.id, notary: FP_OF(k.public_key), retired_at: k.retired_at || null, payload });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return res.status(400).json({ error: 'verify_failed', detail: e.message });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── Web of trust: cross-attestation across multiple notaries ─────────────
|
|
231
|
+
// `data/notary-peers.json` is an array of peer notary base URLs (e.g.
|
|
232
|
+
// "https://notary.other-example.org/api/notary"). POST /cross-attest fans out
|
|
233
|
+
// to each peer's /attest/:host (or /attest with JSON body), collects the
|
|
234
|
+
// signed receipts, and returns them alongside our own. The caller verifies
|
|
235
|
+
// each receipt independently using the matching notary's public key (which
|
|
236
|
+
// each peer publishes at <base>/keys).
|
|
237
|
+
async function fetchPeerAttestation(base, host) {
|
|
238
|
+
const ac = new AbortController();
|
|
239
|
+
const t = setTimeout(() => ac.abort(), 5000);
|
|
240
|
+
try {
|
|
241
|
+
const r = await fetch(`${base.replace(/\/+$/, '')}/attest/${encodeURIComponent(host)}`, {
|
|
242
|
+
signal: ac.signal,
|
|
243
|
+
headers: { accept: 'application/json' }
|
|
244
|
+
});
|
|
245
|
+
clearTimeout(t);
|
|
246
|
+
if (!r.ok) return { peer: base, error: `http_${r.status}` };
|
|
247
|
+
const j = await r.json();
|
|
248
|
+
return { peer: base, attestation: j };
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return { peer: base, error: e.name === 'AbortError' ? 'timeout' : e.message };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
router.post('/cross-attest', express.json({ limit: '4kb' }), async (req, res) => {
|
|
255
|
+
const host = String(req.body && req.body.host || '').trim().toLowerCase()
|
|
256
|
+
.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
257
|
+
if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
|
|
258
|
+
const peers = safeReadJson(PEERS_PATH, []);
|
|
259
|
+
const own = await observe(host).catch(e => ({ error: e.message }));
|
|
260
|
+
const peerResults = peers.length
|
|
261
|
+
? await Promise.all(peers.map(p => fetchPeerAttestation(p, host)))
|
|
262
|
+
: [];
|
|
263
|
+
// Consensus: how many notaries (including us) agree on status + sha256.
|
|
264
|
+
const all = [own, ...peerResults.filter(p => p.attestation).map(p => p.attestation)];
|
|
265
|
+
const buckets = new Map();
|
|
266
|
+
for (const a of all) {
|
|
267
|
+
if (!a || !a.status) continue;
|
|
268
|
+
const k = `${a.status}|${a.manifest_sha256 || ''}`;
|
|
269
|
+
buckets.set(k, (buckets.get(k) || 0) + 1);
|
|
270
|
+
}
|
|
271
|
+
let consensus = null, top = 0;
|
|
272
|
+
for (const [k, n] of buckets) if (n > top) { top = n; consensus = k; }
|
|
273
|
+
res.set('Cache-Control', 'public, max-age=60');
|
|
274
|
+
res.json({
|
|
275
|
+
host,
|
|
276
|
+
own: own,
|
|
277
|
+
peers: peerResults,
|
|
278
|
+
consensus: consensus ? {
|
|
279
|
+
status: consensus.split('|')[0],
|
|
280
|
+
manifest_sha256: consensus.split('|')[1] || null,
|
|
281
|
+
votes: top,
|
|
282
|
+
total: all.length
|
|
283
|
+
} : null,
|
|
284
|
+
generated_at: new Date().toISOString()
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Admin: list / replace peer list. Public GET shows just the URLs.
|
|
289
|
+
router.get('/peers', (req, res) => {
|
|
290
|
+
res.set('Cache-Control', 'public, max-age=300');
|
|
291
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
292
|
+
res.json({ peers: safeReadJson(PEERS_PATH, []) });
|
|
293
|
+
});
|
|
294
|
+
router.put('/admin/peers', express.json({ limit: '8kb' }), (req, res) => {
|
|
295
|
+
if (!ADMIN_TOKEN || !safeEqual(req.get('x-admin-token'), ADMIN_TOKEN)) {
|
|
296
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
297
|
+
}
|
|
298
|
+
const peers = Array.isArray(req.body && req.body.peers) ? req.body.peers : null;
|
|
299
|
+
if (!peers) return res.status(400).json({ error: 'expected_peers_array' });
|
|
300
|
+
const clean = peers
|
|
301
|
+
.filter(p => typeof p === 'string' && /^https?:\/\//i.test(p))
|
|
302
|
+
.slice(0, 50);
|
|
303
|
+
safeWriteJson(PEERS_PATH, clean);
|
|
304
|
+
res.json({ saved: clean.length, peers: clean });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
module.exports = router;
|
|
308
|
+
module.exports.currentPublicKey = () => CURRENT().public_key;
|
|
309
|
+
module.exports.currentFingerprint = () => FP_OF(CURRENT().public_key);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// WAB Observatory — public registry of known WAB-enabled domains.
|
|
2
|
+
//
|
|
3
|
+
// Sources (cheap, additive):
|
|
4
|
+
// 1. data/observatory-seed.json — manual seed list (curated).
|
|
5
|
+
// 2. data/observatory-cache.json — domains that have hit /api/notary,
|
|
6
|
+
// /badge/:domain, or /check at least once.
|
|
7
|
+
//
|
|
8
|
+
// Both files are JSON arrays of host strings. We deduplicate and re-probe
|
|
9
|
+
// each host every 30 minutes via the Notary attestation cache so the
|
|
10
|
+
// "verified / enabled / missing" status stays fresh.
|
|
11
|
+
|
|
12
|
+
const express = require('express');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const router = express.Router();
|
|
17
|
+
|
|
18
|
+
const SEED_PATH = path.join(__dirname, '..', '..', 'data', 'observatory-seed.json');
|
|
19
|
+
const CACHE_PATH = path.join(__dirname, '..', '..', 'data', 'observatory-cache.json');
|
|
20
|
+
const HOST_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
|
|
21
|
+
|
|
22
|
+
function readJsonArray(p) {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.existsSync(p)) return [];
|
|
25
|
+
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
26
|
+
return Array.isArray(j) ? j.filter(h => typeof h === 'string' && HOST_RE.test(h)) : [];
|
|
27
|
+
} catch (_) { return []; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeJsonArray(p, arr) {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
33
|
+
fs.writeFileSync(p, JSON.stringify(arr, null, 2));
|
|
34
|
+
} catch (_) {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// In-memory status cache (host -> { status, signed, observed_at, exp })
|
|
38
|
+
const _status = new Map();
|
|
39
|
+
const TTL = 30 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
async function probe(host) {
|
|
42
|
+
const cached = _status.get(host);
|
|
43
|
+
if (cached && cached.exp > Date.now()) return cached;
|
|
44
|
+
const ac = new AbortController();
|
|
45
|
+
const t = setTimeout(() => ac.abort(), 3500);
|
|
46
|
+
let status = 'missing', signed = false;
|
|
47
|
+
try {
|
|
48
|
+
const r = await fetch(`https://${host}/.well-known/wab.json`, { signal: ac.signal, redirect: 'follow' });
|
|
49
|
+
if (r.ok) {
|
|
50
|
+
const j = await r.json().catch(() => null);
|
|
51
|
+
if (j) {
|
|
52
|
+
signed = !!(j.signature || (j.trust && j.trust.signed));
|
|
53
|
+
status = signed ? 'verified' : 'enabled';
|
|
54
|
+
} else { status = 'invalid'; }
|
|
55
|
+
}
|
|
56
|
+
} catch (_) {}
|
|
57
|
+
finally { clearTimeout(t); }
|
|
58
|
+
const rec = { host, status, signed, observed_at: new Date().toISOString(), exp: Date.now() + TTL };
|
|
59
|
+
_status.set(host, rec);
|
|
60
|
+
return rec;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function knownHosts() {
|
|
64
|
+
const seed = readJsonArray(SEED_PATH);
|
|
65
|
+
const cache = readJsonArray(CACHE_PATH);
|
|
66
|
+
return Array.from(new Set([...seed, ...cache])).sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
router.post('/track', express.json({ limit: '1kb' }), (req, res) => {
|
|
70
|
+
const host = String(req.body && req.body.host || '').trim().toLowerCase()
|
|
71
|
+
.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
72
|
+
if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
|
|
73
|
+
const cache = readJsonArray(CACHE_PATH);
|
|
74
|
+
if (!cache.includes(host)) {
|
|
75
|
+
cache.push(host);
|
|
76
|
+
writeJsonArray(CACHE_PATH, cache);
|
|
77
|
+
}
|
|
78
|
+
res.json({ tracked: true, host });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
router.get('/domains', async (req, res) => {
|
|
82
|
+
const limit = Math.max(1, Math.min(500, Number(req.query.limit) || 200));
|
|
83
|
+
const hosts = knownHosts().slice(0, limit);
|
|
84
|
+
const results = await Promise.all(hosts.map(h => probe(h).catch(() => ({ host: h, status: 'missing', signed: false }))));
|
|
85
|
+
res.set('Cache-Control', 'public, max-age=120');
|
|
86
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
87
|
+
res.json({
|
|
88
|
+
count: results.length,
|
|
89
|
+
generated_at: new Date().toISOString(),
|
|
90
|
+
domains: results.map(({ exp, ...r }) => r)
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
router.get('/stats', async (req, res) => {
|
|
95
|
+
const hosts = knownHosts();
|
|
96
|
+
const results = await Promise.all(hosts.map(h => probe(h).catch(() => ({ status: 'missing', signed: false }))));
|
|
97
|
+
const by = { verified: 0, enabled: 0, missing: 0, invalid: 0 };
|
|
98
|
+
for (const r of results) by[r.status] = (by[r.status] || 0) + 1;
|
|
99
|
+
res.set('Cache-Control', 'public, max-age=120');
|
|
100
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
101
|
+
res.json({
|
|
102
|
+
total: results.length,
|
|
103
|
+
signed: results.filter(r => r.signed).length,
|
|
104
|
+
by_status: by,
|
|
105
|
+
generated_at: new Date().toISOString()
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
module.exports = router;
|
|
@@ -38,10 +38,11 @@ function hashIp(ip) {
|
|
|
38
38
|
function clip(s, n = 2000) { return typeof s === 'string' ? s.slice(0, n) : ''; }
|
|
39
39
|
|
|
40
40
|
function adminGate(req, res, next) {
|
|
41
|
+
const { safeEqual } = require('../utils/safe-compare');
|
|
41
42
|
const expected = process.env.WAB_PARTNERS_ADMIN_TOKEN || process.env.WAB_RING4_ADMIN_TOKEN;
|
|
42
43
|
if (!expected) return res.status(503).json({ error: 'admin_disabled' });
|
|
43
44
|
const presented = req.headers['x-admin-token'] || '';
|
|
44
|
-
if (presented
|
|
45
|
+
if (!safeEqual(presented, expected)) return res.status(401).json({ error: 'unauthorized' });
|
|
45
46
|
next();
|
|
46
47
|
}
|
|
47
48
|
|