@meshwhisper/node 0.1.1 → 0.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/dist/federation.d.ts +99 -0
- package/dist/federation.d.ts.map +1 -0
- package/dist/federation.js +539 -0
- package/dist/federation.js.map +1 -0
- package/dist/index.js +849 -50
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -31,24 +31,47 @@
|
|
|
31
31
|
// ============================================================
|
|
32
32
|
import * as http from 'node:http';
|
|
33
33
|
import * as https from 'node:https';
|
|
34
|
+
import * as nodeCrypto from 'node:crypto';
|
|
35
|
+
import * as path from 'node:path';
|
|
34
36
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
35
37
|
import Database from 'better-sqlite3';
|
|
38
|
+
import { FederationManager, FEDERATION_SUBPROTOCOL, loadOrCreateFederationKey, loadPeersConfig, loadBlocklist, } from './federation.js';
|
|
36
39
|
// ============================================================
|
|
37
40
|
// Configuration
|
|
38
41
|
// ============================================================
|
|
39
42
|
const PORT = parseInt(process.env.PORT ?? '8080', 10);
|
|
40
|
-
|
|
43
|
+
// Default 30 days. The previous 72h was tight enough that anyone offline
|
|
44
|
+
// for a long weekend lost messages. Storage cost is small in absolute terms
|
|
45
|
+
// because chaff dominates the relay's traffic, not real blobs. SDK clients
|
|
46
|
+
// listen on the same window to ensure blobs aren't stranded under
|
|
47
|
+
// destHashes the recipient stops asking for.
|
|
48
|
+
const BLOB_TTL_HOURS = parseInt(process.env.BLOB_TTL_HOURS ?? '720', 10);
|
|
41
49
|
const MAX_BLOB_SIZE = parseInt(process.env.MAX_BLOB_SIZE ?? String(256 * 1024), 10); // 256 KB
|
|
42
50
|
const MAX_BLOBS_PER_HASH = parseInt(process.env.MAX_BLOBS_PER_HASH ?? '500', 10);
|
|
43
51
|
const MEDIA_TTL_HOURS = parseInt(process.env.MEDIA_TTL_HOURS ?? String(7 * 24), 10); // 7 days
|
|
52
|
+
const MAX_ARCHIVE_SIZE = parseInt(process.env.MAX_ARCHIVE_SIZE ?? String(12 * 1024 * 1024), 10); // 12 MB
|
|
44
53
|
const MAX_MEDIA_SIZE = parseInt(process.env.MAX_MEDIA_SIZE ?? String(50 * 1024 * 1024), 10); // 50 MB
|
|
45
54
|
const PRUNE_INTERVAL_MS = 5 * 60 * 1000; // prune expired blobs every 5 minutes
|
|
46
55
|
const PUSH_WEBHOOK_URL = process.env.PUSH_WEBHOOK_URL ?? null;
|
|
47
56
|
const DB_PATH = process.env.DB_PATH ?? './meshwhisper.db';
|
|
48
|
-
// Rate limiting (per IP, sliding window)
|
|
57
|
+
// Rate limiting (per IP, sliding window).
|
|
58
|
+
//
|
|
59
|
+
// Buckets are coarse on purpose — one budget per bucket per IP per minute.
|
|
60
|
+
// Operators tune via env vars; the defaults are deliberately generous so
|
|
61
|
+
// well-behaved clients almost never hit them, while still catching obvious
|
|
62
|
+
// abuse (scripted enumeration, blob spam, etc).
|
|
63
|
+
//
|
|
64
|
+
// TRUST_PROXY controls whether X-Forwarded-For is honoured when computing
|
|
65
|
+
// the per-IP key. Default OFF — a direct-exposed node would otherwise let
|
|
66
|
+
// an attacker spoof the header to evade limits. Set TRUST_PROXY=1 (or any
|
|
67
|
+
// truthy value) when running behind nginx / Caddy / Cloudflare etc., which
|
|
68
|
+
// is the expected production deployment shape.
|
|
49
69
|
const RATE_WINDOW_MS = 60_000; // 1 minute window
|
|
50
70
|
const RATE_LIMIT_MEDIA = parseInt(process.env.RATE_LIMIT_MEDIA ?? '20', 10); // uploads/min
|
|
51
|
-
const RATE_LIMIT_DIR = parseInt(process.env.RATE_LIMIT_DIR ?? '60', 10); //
|
|
71
|
+
const RATE_LIMIT_DIR = parseInt(process.env.RATE_LIMIT_DIR ?? '60', 10); // writes/min on directory/opks/policy
|
|
72
|
+
const RATE_LIMIT_READ = parseInt(process.env.RATE_LIMIT_READ ?? '300', 10); // reads/min (GETs)
|
|
73
|
+
const RATE_LIMIT_ARCHIVE = parseInt(process.env.RATE_LIMIT_ARCHIVE ?? '30', 10); // archive PUTs/min
|
|
74
|
+
const TRUST_PROXY = /^(1|true|yes|on)$/i.test(process.env.TRUST_PROXY ?? '');
|
|
52
75
|
/** Canonical external base URL for constructing media download links.
|
|
53
76
|
* Set this when the Node is behind a reverse proxy (nginx, Caddy, Cloudflare).
|
|
54
77
|
* Example: BASE_URL=https://msg.myapp.com
|
|
@@ -61,6 +84,13 @@ const db = new Database(DB_PATH);
|
|
|
61
84
|
// Enable WAL mode for better concurrent read performance
|
|
62
85
|
db.pragma('journal_mode = WAL');
|
|
63
86
|
db.pragma('foreign_keys = ON');
|
|
87
|
+
// Migrate existing prekey_bundles table if columns are missing (added for username support)
|
|
88
|
+
for (const col of ['username', 'namespace']) {
|
|
89
|
+
try {
|
|
90
|
+
db.exec(`ALTER TABLE prekey_bundles ADD COLUMN ${col} TEXT`);
|
|
91
|
+
}
|
|
92
|
+
catch { /* already exists */ }
|
|
93
|
+
}
|
|
64
94
|
db.exec(`
|
|
65
95
|
CREATE TABLE IF NOT EXISTS blobs (
|
|
66
96
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -79,8 +109,10 @@ db.exec(`
|
|
|
79
109
|
);
|
|
80
110
|
|
|
81
111
|
CREATE TABLE IF NOT EXISTS prekey_bundles (
|
|
82
|
-
key
|
|
83
|
-
bundle
|
|
112
|
+
key TEXT PRIMARY KEY,
|
|
113
|
+
bundle TEXT NOT NULL,
|
|
114
|
+
username TEXT,
|
|
115
|
+
namespace TEXT
|
|
84
116
|
);
|
|
85
117
|
|
|
86
118
|
CREATE TABLE IF NOT EXISTS media (
|
|
@@ -88,6 +120,50 @@ db.exec(`
|
|
|
88
120
|
data BLOB NOT NULL,
|
|
89
121
|
stored_at INTEGER NOT NULL
|
|
90
122
|
);
|
|
123
|
+
|
|
124
|
+
CREATE TABLE IF NOT EXISTS opks (
|
|
125
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
126
|
+
identity_key TEXT NOT NULL,
|
|
127
|
+
opk_public TEXT NOT NULL,
|
|
128
|
+
stored_at INTEGER NOT NULL,
|
|
129
|
+
UNIQUE (identity_key, opk_public)
|
|
130
|
+
);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS opks_identity_key ON opks (identity_key);
|
|
132
|
+
|
|
133
|
+
-- TOFU auth for DELETE /opks. Identity_pubkey is the user's Ed25519
|
|
134
|
+
-- public key hex (cross-namespace). First DELETE establishes the hash;
|
|
135
|
+
-- subsequent DELETEs must match.
|
|
136
|
+
CREATE TABLE IF NOT EXISTS opk_auth (
|
|
137
|
+
identity_pubkey TEXT PRIMARY KEY,
|
|
138
|
+
auth_hash TEXT NOT NULL,
|
|
139
|
+
stored_at INTEGER NOT NULL
|
|
140
|
+
);
|
|
141
|
+
CREATE UNIQUE INDEX IF NOT EXISTS prekey_username_idx
|
|
142
|
+
ON prekey_bundles (namespace, username)
|
|
143
|
+
WHERE username IS NOT NULL;
|
|
144
|
+
|
|
145
|
+
CREATE TABLE IF NOT EXISTS archives (
|
|
146
|
+
peer_id TEXT PRIMARY KEY,
|
|
147
|
+
auth_hash TEXT NOT NULL,
|
|
148
|
+
data BLOB NOT NULL,
|
|
149
|
+
stored_at INTEGER NOT NULL,
|
|
150
|
+
size INTEGER NOT NULL
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
-- Per-namespace policy controlling username ownership semantics.
|
|
154
|
+
-- 'signed-transfer' (default for unrecorded namespaces): username is sticky
|
|
155
|
+
-- to whichever identity first claims it; a different key cannot take it
|
|
156
|
+
-- over via a plain re-registration. Re-publishing with the SAME key is
|
|
157
|
+
-- always allowed (covers bundle refresh and key-rotation flows).
|
|
158
|
+
-- 'last-writer-wins': legacy/opt-in behavior — a fresh key claiming a taken
|
|
159
|
+
-- username silently displaces the prior owner. Useful for password-derived
|
|
160
|
+
-- identities where re-deriving the same key from credentials is the
|
|
161
|
+
-- recovery story.
|
|
162
|
+
CREATE TABLE IF NOT EXISTS namespace_policy (
|
|
163
|
+
namespace TEXT PRIMARY KEY,
|
|
164
|
+
username_policy TEXT NOT NULL CHECK (username_policy IN ('signed-transfer', 'last-writer-wins')),
|
|
165
|
+
set_at INTEGER NOT NULL
|
|
166
|
+
);
|
|
91
167
|
`);
|
|
92
168
|
// Prepared statements
|
|
93
169
|
const stmts = {
|
|
@@ -112,37 +188,103 @@ const stmts = {
|
|
|
112
188
|
deletePush: db.prepare('DELETE FROM push_registrations WHERE dest_hash = ?'),
|
|
113
189
|
countPush: db.prepare('SELECT COUNT(*) AS cnt FROM push_registrations'),
|
|
114
190
|
// prekey bundles
|
|
115
|
-
upsertPrekey: db.prepare(`INSERT INTO prekey_bundles (key, bundle) VALUES (?, ?)
|
|
116
|
-
ON CONFLICT(key) DO UPDATE SET
|
|
117
|
-
|
|
191
|
+
upsertPrekey: db.prepare(`INSERT INTO prekey_bundles (key, bundle, username, namespace) VALUES (?, ?, ?, ?)
|
|
192
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
193
|
+
bundle = excluded.bundle,
|
|
194
|
+
username = COALESCE(excluded.username, username),
|
|
195
|
+
namespace = COALESCE(excluded.namespace, namespace)`),
|
|
196
|
+
getPrekey: db.prepare('SELECT bundle, username FROM prekey_bundles WHERE key = ?'),
|
|
197
|
+
getPrekeyByUsername: db.prepare('SELECT key, bundle FROM prekey_bundles WHERE namespace = ? AND username = ?'),
|
|
118
198
|
countPrekeys: db.prepare('SELECT COUNT(*) AS cnt FROM prekey_bundles'),
|
|
199
|
+
// opks
|
|
200
|
+
insertOpk: db.prepare('INSERT OR IGNORE INTO opks (identity_key, opk_public, stored_at) VALUES (?, ?, ?)'),
|
|
201
|
+
countOpksForKey: db.prepare('SELECT COUNT(*) AS cnt FROM opks WHERE identity_key = ?'),
|
|
202
|
+
countOpks: db.prepare('SELECT COUNT(*) AS cnt FROM opks'),
|
|
203
|
+
// Purges every OPK row for one (namespace, publicKey) pair. Used by the
|
|
204
|
+
// SDK's one-shot migration after the OPK-resurrection fix to clear out
|
|
205
|
+
// zombie entries that the buggy bulk re-upload left behind. The auth_hash
|
|
206
|
+
// check (separate TOFU table below) prevents anyone other than the
|
|
207
|
+
// identity owner from purging the pool.
|
|
208
|
+
deleteOpksForKey: db.prepare('DELETE FROM opks WHERE identity_key = ?'),
|
|
209
|
+
// opk_auth (TOFU per identity)
|
|
210
|
+
getOpkAuthHash: db.prepare('SELECT auth_hash FROM opk_auth WHERE identity_pubkey = ?'),
|
|
211
|
+
insertOpkAuthHash: db.prepare('INSERT OR IGNORE INTO opk_auth (identity_pubkey, auth_hash, stored_at) VALUES (?, ?, ?)'),
|
|
119
212
|
// media
|
|
120
213
|
insertMedia: db.prepare('INSERT INTO media (id, data, stored_at) VALUES (?, ?, ?)'),
|
|
121
214
|
getMedia: db.prepare('SELECT data, stored_at FROM media WHERE id = ?'),
|
|
122
215
|
deleteMedia: db.prepare('DELETE FROM media WHERE id = ?'),
|
|
123
216
|
pruneMedia: db.prepare('DELETE FROM media WHERE stored_at < ?'),
|
|
124
217
|
countMedia: db.prepare('SELECT COUNT(*) AS cnt FROM media'),
|
|
218
|
+
// namespace policy
|
|
219
|
+
getNamespacePolicy: db.prepare('SELECT username_policy FROM namespace_policy WHERE namespace = ?'),
|
|
220
|
+
insertNamespacePolicy: db.prepare('INSERT OR IGNORE INTO namespace_policy (namespace, username_policy, set_at) VALUES (?, ?, ?)'),
|
|
221
|
+
// archives
|
|
222
|
+
upsertArchive: db.prepare(`INSERT INTO archives (peer_id, auth_hash, data, stored_at, size)
|
|
223
|
+
VALUES (?, ?, ?, ?, ?)
|
|
224
|
+
ON CONFLICT(peer_id) DO UPDATE SET
|
|
225
|
+
data = excluded.data,
|
|
226
|
+
stored_at = excluded.stored_at,
|
|
227
|
+
size = excluded.size
|
|
228
|
+
WHERE auth_hash = excluded.auth_hash`),
|
|
229
|
+
getArchiveAuthHash: db.prepare('SELECT auth_hash FROM archives WHERE peer_id = ?'),
|
|
230
|
+
getArchiveData: db.prepare('SELECT data FROM archives WHERE peer_id = ?'),
|
|
231
|
+
insertArchiveFirstTime: db.prepare('INSERT OR IGNORE INTO archives (peer_id, auth_hash, data, stored_at, size) VALUES (?, ?, ?, ?, ?)'),
|
|
232
|
+
countArchives: db.prepare('SELECT COUNT(*) AS cnt FROM archives'),
|
|
125
233
|
};
|
|
234
|
+
// Atomic OPK claim: SELECT + DELETE in a single transaction so two concurrent
|
|
235
|
+
// initiators can never claim the same one-time pre-key.
|
|
236
|
+
const claimOpkTx = db.transaction((identityKey) => {
|
|
237
|
+
const row = db.prepare('SELECT id, opk_public FROM opks WHERE identity_key = ? LIMIT 1').get(identityKey);
|
|
238
|
+
if (!row)
|
|
239
|
+
return null;
|
|
240
|
+
db.prepare('DELETE FROM opks WHERE id = ?').run(row.id);
|
|
241
|
+
return row.opk_public;
|
|
242
|
+
});
|
|
126
243
|
const rateLimitState = new Map();
|
|
127
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Returns ok=true if the request is within limits. On rejection, retryAfterSeconds
|
|
246
|
+
* is the number of whole seconds until the current window rolls over (clamped to ≥1).
|
|
247
|
+
*/
|
|
128
248
|
function checkRateLimit(ip, bucket, maxPerWindow) {
|
|
129
249
|
const key = `${bucket}:${ip}`;
|
|
130
250
|
const now = Date.now();
|
|
131
251
|
const entry = rateLimitState.get(key);
|
|
132
252
|
if (!entry || now - entry.windowStart >= RATE_WINDOW_MS) {
|
|
133
253
|
rateLimitState.set(key, { count: 1, windowStart: now });
|
|
134
|
-
return true;
|
|
254
|
+
return { ok: true, retryAfterSeconds: 0 };
|
|
255
|
+
}
|
|
256
|
+
if (entry.count >= maxPerWindow) {
|
|
257
|
+
const remainingMs = RATE_WINDOW_MS - (now - entry.windowStart);
|
|
258
|
+
return { ok: false, retryAfterSeconds: Math.max(1, Math.ceil(remainingMs / 1000)) };
|
|
135
259
|
}
|
|
136
|
-
if (entry.count >= maxPerWindow)
|
|
137
|
-
return false;
|
|
138
260
|
entry.count++;
|
|
261
|
+
return { ok: true, retryAfterSeconds: 0 };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Apply a rate limit at the start of a request handler. On rejection writes
|
|
265
|
+
* a 429 with a Retry-After header and returns true (caller should `return`).
|
|
266
|
+
* On accept returns false (caller continues).
|
|
267
|
+
*/
|
|
268
|
+
function rateLimited(req, res, bucket, maxPerWindow) {
|
|
269
|
+
const r = checkRateLimit(getClientIp(req), bucket, maxPerWindow);
|
|
270
|
+
if (r.ok)
|
|
271
|
+
return false;
|
|
272
|
+
if (metrics.rateLimitRejections[bucket] !== undefined) {
|
|
273
|
+
metrics.rateLimitRejections[bucket]++;
|
|
274
|
+
}
|
|
275
|
+
res.setHeader('Retry-After', String(r.retryAfterSeconds));
|
|
276
|
+
sendJson(res, 429, { error: 'Too many requests', retryAfter: r.retryAfterSeconds });
|
|
139
277
|
return true;
|
|
140
278
|
}
|
|
141
279
|
function getClientIp(req) {
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
280
|
+
// X-Forwarded-For is only honoured when the operator has set TRUST_PROXY,
|
|
281
|
+
// otherwise an attacker on a direct-exposed node could spoof the header
|
|
282
|
+
// to evade per-IP rate limiting.
|
|
283
|
+
if (TRUST_PROXY) {
|
|
284
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
285
|
+
if (typeof forwarded === 'string')
|
|
286
|
+
return forwarded.split(',')[0].trim();
|
|
287
|
+
}
|
|
146
288
|
return req.socket.remoteAddress ?? 'unknown';
|
|
147
289
|
}
|
|
148
290
|
function pruneRateLimitState() {
|
|
@@ -153,6 +295,28 @@ function pruneRateLimitState() {
|
|
|
153
295
|
}
|
|
154
296
|
}
|
|
155
297
|
// ============================================================
|
|
298
|
+
// Observability counters — surfaced via the /metrics endpoint in
|
|
299
|
+
// Prometheus text exposition format. Cheap, in-process, reset on
|
|
300
|
+
// every restart (which is what Prometheus expects from counters).
|
|
301
|
+
// ============================================================
|
|
302
|
+
const NODE_STARTED_AT_MS = Date.now();
|
|
303
|
+
const metrics = {
|
|
304
|
+
httpRequestsTotal: 0,
|
|
305
|
+
// Indexed by Prometheus-style status family label ("2xx", "3xx", etc.)
|
|
306
|
+
// plus exact "429" because that's the one operators alert on most.
|
|
307
|
+
httpStatus: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, '429': 0 },
|
|
308
|
+
rateLimitRejections: { dir: 0, media: 0, read: 0, archive: 0 },
|
|
309
|
+
websocketConnectionsTotal: 0,
|
|
310
|
+
};
|
|
311
|
+
function recordHttpStatus(status) {
|
|
312
|
+
metrics.httpRequestsTotal++;
|
|
313
|
+
if (status === 429)
|
|
314
|
+
metrics.httpStatus['429']++;
|
|
315
|
+
const family = `${Math.floor(status / 100)}xx`;
|
|
316
|
+
if (metrics.httpStatus[family] !== undefined)
|
|
317
|
+
metrics.httpStatus[family]++;
|
|
318
|
+
}
|
|
319
|
+
// ============================================================
|
|
156
320
|
// Packet header constants (must match SDK wire format)
|
|
157
321
|
//
|
|
158
322
|
// Binary layout (all big-endian):
|
|
@@ -184,7 +348,8 @@ function storeBlob(destHash, data) {
|
|
|
184
348
|
if (oldest)
|
|
185
349
|
stmts.deleteBlob.run(oldest.id);
|
|
186
350
|
}
|
|
187
|
-
stmts.insertBlob.run(destHash, Buffer.from(data), Date.now());
|
|
351
|
+
const result = stmts.insertBlob.run(destHash, Buffer.from(data), Date.now());
|
|
352
|
+
return Number(result.lastInsertRowid);
|
|
188
353
|
}
|
|
189
354
|
function pullBlobs(destHash) {
|
|
190
355
|
const rows = stmts.pullBlobs.all(destHash);
|
|
@@ -252,12 +417,124 @@ function notifyPush(destHash, reg) {
|
|
|
252
417
|
function directoryKey(namespace, publicKey) {
|
|
253
418
|
return `${namespace}:${publicKey}`;
|
|
254
419
|
}
|
|
255
|
-
|
|
256
|
-
|
|
420
|
+
const USERNAME_RE = /^[a-z0-9_-]{3,30}$/;
|
|
421
|
+
function validateUsername(raw) {
|
|
422
|
+
const u = raw.toLowerCase().trim();
|
|
423
|
+
return USERNAME_RE.test(u) ? u : null;
|
|
424
|
+
}
|
|
425
|
+
function getNamespaceUsernamePolicy(namespace) {
|
|
426
|
+
const row = stmts.getNamespacePolicy.get(namespace);
|
|
427
|
+
return row?.username_policy ?? 'signed-transfer';
|
|
428
|
+
}
|
|
429
|
+
// Wire format for username-transfer authorizations. The signed bytes MUST
|
|
430
|
+
// match what the SDK produces; see `src/sdk/username-transfer.ts`. Bumping
|
|
431
|
+
// the version tag breaks old tokens — fine, transfer tokens are short-lived.
|
|
432
|
+
function canonicalTransferMessage(namespace, username, toPublicKeyHex, expiresAt) {
|
|
433
|
+
return Buffer.from([
|
|
434
|
+
'meshwhisper.username-transfer.v1',
|
|
435
|
+
namespace,
|
|
436
|
+
username,
|
|
437
|
+
toPublicKeyHex,
|
|
438
|
+
String(expiresAt),
|
|
439
|
+
].join('\n'), 'utf8');
|
|
440
|
+
}
|
|
441
|
+
// DER SubjectPublicKeyInfo prefix for a raw Ed25519 public key.
|
|
442
|
+
// Lets `nodeCrypto.createPublicKey` ingest a 32-byte key without
|
|
443
|
+
// pulling in an external Ed25519 library.
|
|
444
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
445
|
+
function verifyTransferAuth(namespace, username, newOwnerPublicKeyHex, currentOwnerPublicKeyHex, auth) {
|
|
446
|
+
if (typeof auth.expiresAt !== 'number' || !Number.isFinite(auth.expiresAt))
|
|
447
|
+
return false;
|
|
448
|
+
if (Date.now() > auth.expiresAt)
|
|
449
|
+
return false;
|
|
450
|
+
// fromPublicKey in the request is informational; the relay binds
|
|
451
|
+
// verification to the actual current owner. We still cross-check
|
|
452
|
+
// so an obviously-mismatched token is rejected early.
|
|
453
|
+
if (typeof auth.fromPublicKey === 'string' &&
|
|
454
|
+
auth.fromPublicKey.toLowerCase() !== currentOwnerPublicKeyHex.toLowerCase()) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
let signatureBytes;
|
|
458
|
+
try {
|
|
459
|
+
signatureBytes = Buffer.from(auth.signature, 'base64');
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
if (signatureBytes.length !== 64)
|
|
465
|
+
return false;
|
|
466
|
+
let publicKeyBytes;
|
|
467
|
+
try {
|
|
468
|
+
publicKeyBytes = Buffer.from(currentOwnerPublicKeyHex, 'hex');
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
if (publicKeyBytes.length !== 32)
|
|
474
|
+
return false;
|
|
475
|
+
let publicKey;
|
|
476
|
+
try {
|
|
477
|
+
publicKey = nodeCrypto.createPublicKey({
|
|
478
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, publicKeyBytes]),
|
|
479
|
+
format: 'der',
|
|
480
|
+
type: 'spki',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
const message = canonicalTransferMessage(namespace, username, newOwnerPublicKeyHex, auth.expiresAt);
|
|
487
|
+
try {
|
|
488
|
+
return nodeCrypto.verify(null, message, publicKey, signatureBytes);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
257
493
|
}
|
|
258
|
-
|
|
494
|
+
const registerPrekeyTx = db.transaction((namespace, publicKey, bundle, username, transferAuth) => {
|
|
495
|
+
const key = directoryKey(namespace, publicKey);
|
|
496
|
+
if (username) {
|
|
497
|
+
const existing = stmts.getPrekeyByUsername.get(namespace, username);
|
|
498
|
+
if (existing && existing.key !== key) {
|
|
499
|
+
const policy = getNamespaceUsernamePolicy(namespace);
|
|
500
|
+
if (policy === 'last-writer-wins') {
|
|
501
|
+
// Legacy/opt-in: displace the prior owner without proof.
|
|
502
|
+
db.prepare('DELETE FROM prekey_bundles WHERE key = ?').run(existing.key);
|
|
503
|
+
}
|
|
504
|
+
else if (transferAuth) {
|
|
505
|
+
// Signed-transfer policy with handover token: verify the prior owner
|
|
506
|
+
// authorized this takeover. existing.key is `${namespace}:${publicKey}`;
|
|
507
|
+
// splitting on the last colon recovers the publicKey hex (which itself
|
|
508
|
+
// never contains a colon).
|
|
509
|
+
const lastColon = existing.key.lastIndexOf(':');
|
|
510
|
+
const currentOwnerHex = lastColon >= 0 ? existing.key.slice(lastColon + 1) : '';
|
|
511
|
+
const ok = verifyTransferAuth(namespace, username, publicKey, currentOwnerHex, transferAuth);
|
|
512
|
+
if (!ok)
|
|
513
|
+
return 'invalid_transfer_auth';
|
|
514
|
+
db.prepare('DELETE FROM prekey_bundles WHERE key = ?').run(existing.key);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// Signed-transfer policy, no token — reject takeover.
|
|
518
|
+
return 'username_taken';
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
stmts.upsertPrekey.run(key, bundle, username, namespace);
|
|
523
|
+
return 'ok';
|
|
524
|
+
});
|
|
525
|
+
function lookupPrekeyByPublicKey(namespace, publicKey) {
|
|
259
526
|
const row = stmts.getPrekey.get(directoryKey(namespace, publicKey));
|
|
260
|
-
|
|
527
|
+
if (!row)
|
|
528
|
+
return null;
|
|
529
|
+
return { bundle: row.bundle, ...(row.username ? { username: row.username } : {}) };
|
|
530
|
+
}
|
|
531
|
+
function lookupPrekeyByUsername(namespace, username) {
|
|
532
|
+
const row = stmts.getPrekeyByUsername.get(namespace, username);
|
|
533
|
+
if (!row)
|
|
534
|
+
return null;
|
|
535
|
+
// key format is "namespace:publicKey"
|
|
536
|
+
const publicKey = row.key.slice(namespace.length + 1);
|
|
537
|
+
return { bundle: row.bundle, publicKey };
|
|
261
538
|
}
|
|
262
539
|
// ============================================================
|
|
263
540
|
// Media store — SQLite-backed, TTL-based encrypted blob storage
|
|
@@ -296,6 +573,32 @@ function pruneExpiredMedia() {
|
|
|
296
573
|
const clientsByHash = new Map();
|
|
297
574
|
/** Reverse map so we can clean up on disconnect. */
|
|
298
575
|
const hashesPerClient = new Map();
|
|
576
|
+
const activitySubscribers = new Set();
|
|
577
|
+
let activityBucket = { in: 0, fwd: 0, queue: 0, wake: 0, drain: 0 };
|
|
578
|
+
function bumpActivity(type) {
|
|
579
|
+
if (activitySubscribers.size === 0)
|
|
580
|
+
return; // no-op when nobody is watching
|
|
581
|
+
activityBucket[type] += 1;
|
|
582
|
+
}
|
|
583
|
+
function flushActivity() {
|
|
584
|
+
if (activitySubscribers.size === 0)
|
|
585
|
+
return;
|
|
586
|
+
const total = activityBucket.in + activityBucket.fwd + activityBucket.queue +
|
|
587
|
+
activityBucket.wake + activityBucket.drain;
|
|
588
|
+
if (total === 0)
|
|
589
|
+
return;
|
|
590
|
+
const payload = `data: ${JSON.stringify(activityBucket)}\n\n`;
|
|
591
|
+
for (const res of activitySubscribers) {
|
|
592
|
+
try {
|
|
593
|
+
res.write(payload);
|
|
594
|
+
}
|
|
595
|
+
catch { /* dead connection — cleaned on close */ }
|
|
596
|
+
}
|
|
597
|
+
activityBucket = { in: 0, fwd: 0, queue: 0, wake: 0, drain: 0 };
|
|
598
|
+
}
|
|
599
|
+
// Flush at ~4 Hz: fast enough to feel live, slow enough to obscure
|
|
600
|
+
// individual sends and keep CPU usage trivial.
|
|
601
|
+
setInterval(flushActivity, 250);
|
|
299
602
|
function registerClient(ws, destHashes) {
|
|
300
603
|
const existing = hashesPerClient.get(ws);
|
|
301
604
|
if (existing) {
|
|
@@ -325,6 +628,7 @@ function deliverQueuedBlobs(ws, destHashes) {
|
|
|
325
628
|
for (const blob of blobs) {
|
|
326
629
|
if (ws.readyState === WebSocket.OPEN) {
|
|
327
630
|
ws.send(blob, { binary: true });
|
|
631
|
+
bumpActivity('drain');
|
|
328
632
|
}
|
|
329
633
|
}
|
|
330
634
|
}
|
|
@@ -338,34 +642,61 @@ function handleRelayPacket(data, sender) {
|
|
|
338
642
|
const destHash = readDestHash(data);
|
|
339
643
|
if (!destHash)
|
|
340
644
|
return; // malformed header
|
|
645
|
+
bumpActivity('in');
|
|
646
|
+
// Always store the blob first so it survives regardless of delivery outcome.
|
|
647
|
+
// This prevents a race where the recipient's WebSocket is still in the
|
|
648
|
+
// registry (close event not yet fired) but the peer has already called
|
|
649
|
+
// terminate(): we'd forward to the stale socket and the store-and-forward
|
|
650
|
+
// delivery on reconnect would never happen.
|
|
651
|
+
const blobId = storeBlob(destHash, data);
|
|
341
652
|
const recipient = clientsByHash.get(destHash);
|
|
342
653
|
if (recipient && recipient !== sender && recipient.readyState === WebSocket.OPEN) {
|
|
343
|
-
// Recipient
|
|
344
|
-
|
|
654
|
+
// Recipient appears connected — deliver immediately AND delete from store.
|
|
655
|
+
// If the send fails (connection dropped between readyState check and write),
|
|
656
|
+
// the blob stays in the store and will be delivered on the next reconnect.
|
|
657
|
+
recipient.send(data, { binary: true }, (err) => {
|
|
658
|
+
if (!err) {
|
|
659
|
+
// Delivery succeeded — purge from store so it isn't delivered twice
|
|
660
|
+
stmts.deleteBlob.run(blobId);
|
|
661
|
+
bumpActivity('fwd');
|
|
662
|
+
}
|
|
663
|
+
// On error: blob remains in store for reconnect delivery
|
|
664
|
+
});
|
|
345
665
|
}
|
|
346
666
|
else {
|
|
347
|
-
// Recipient offline (or same socket) —
|
|
348
|
-
|
|
349
|
-
// Wake the recipient via push if they have a registered token
|
|
667
|
+
// Recipient offline (or same socket) — already stored above.
|
|
668
|
+
bumpActivity('queue');
|
|
669
|
+
// Wake the recipient via push if they have a registered token.
|
|
350
670
|
const pushReg = getPushRegistration(destHash);
|
|
351
|
-
if (pushReg)
|
|
671
|
+
if (pushReg) {
|
|
352
672
|
notifyPush(destHash, pushReg);
|
|
673
|
+
bumpActivity('wake');
|
|
674
|
+
}
|
|
675
|
+
else if (federation) {
|
|
676
|
+
// No connected client and no push registration — this destHash may
|
|
677
|
+
// be homed on a federated peer. Forward best-effort. The local
|
|
678
|
+
// store (above) is kept as a harmless safety net; it expires per
|
|
679
|
+
// BLOB_TTL like everything else.
|
|
680
|
+
federation.forwardFromLocal(data);
|
|
681
|
+
}
|
|
353
682
|
}
|
|
354
683
|
}
|
|
355
684
|
// ============================================================
|
|
356
685
|
// WebSocket server
|
|
357
686
|
// ============================================================
|
|
358
687
|
function handleWebSocketConnection(ws) {
|
|
359
|
-
ws.on('message', (raw) => {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
688
|
+
ws.on('message', (raw, isBinary) => {
|
|
689
|
+
// Binary frames are relay packets
|
|
690
|
+
if (isBinary) {
|
|
691
|
+
if (raw instanceof ArrayBuffer) {
|
|
692
|
+
handleRelayPacket(new Uint8Array(raw), ws);
|
|
693
|
+
}
|
|
694
|
+
else if (Buffer.isBuffer(raw)) {
|
|
695
|
+
handleRelayPacket(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength), ws);
|
|
696
|
+
}
|
|
366
697
|
return;
|
|
367
698
|
}
|
|
368
|
-
// JSON control
|
|
699
|
+
// Text frames are JSON control messages
|
|
369
700
|
try {
|
|
370
701
|
const msg = JSON.parse(raw.toString());
|
|
371
702
|
if (msg.type === 'hello' && Array.isArray(msg.destHashes)) {
|
|
@@ -422,6 +753,7 @@ function sendJson(res, status, body) {
|
|
|
422
753
|
'Access-Control-Allow-Origin': '*',
|
|
423
754
|
});
|
|
424
755
|
res.end(payload);
|
|
756
|
+
recordHttpStatus(status);
|
|
425
757
|
}
|
|
426
758
|
async function handleHttp(req, res) {
|
|
427
759
|
const url = new URL(req.url ?? '/', `http://localhost`);
|
|
@@ -430,12 +762,42 @@ async function handleHttp(req, res) {
|
|
|
430
762
|
if (method === 'OPTIONS') {
|
|
431
763
|
res.writeHead(204, {
|
|
432
764
|
'Access-Control-Allow-Origin': '*',
|
|
433
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
434
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
765
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
|
|
766
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
435
767
|
});
|
|
436
768
|
res.end();
|
|
437
769
|
return;
|
|
438
770
|
}
|
|
771
|
+
// Anonymous live activity stream (Server-Sent Events) — public, no auth.
|
|
772
|
+
// Emits a JSON object every ~250ms when the relay handled any traffic in
|
|
773
|
+
// that window. Counts only — no destination hashes, no IPs, no per-event
|
|
774
|
+
// metadata. Safe to expose to anyone watching the public site.
|
|
775
|
+
if (url.pathname === '/events' && method === 'GET') {
|
|
776
|
+
// Per-IP cap on SSE subscriptions. Each subscriber holds a long-lived
|
|
777
|
+
// connection; cap prevents a single peer from exhausting socket budget.
|
|
778
|
+
if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
|
|
779
|
+
return;
|
|
780
|
+
res.writeHead(200, {
|
|
781
|
+
'Content-Type': 'text/event-stream',
|
|
782
|
+
'Cache-Control': 'no-cache, no-store',
|
|
783
|
+
'Connection': 'keep-alive',
|
|
784
|
+
'X-Accel-Buffering': 'no', // disable nginx buffering for SSE
|
|
785
|
+
'Access-Control-Allow-Origin': '*',
|
|
786
|
+
});
|
|
787
|
+
res.write(': connected\n\n');
|
|
788
|
+
activitySubscribers.add(res);
|
|
789
|
+
const heartbeat = setInterval(() => {
|
|
790
|
+
try {
|
|
791
|
+
res.write(': hb\n\n');
|
|
792
|
+
}
|
|
793
|
+
catch { /* ignored */ }
|
|
794
|
+
}, 15_000);
|
|
795
|
+
req.on('close', () => {
|
|
796
|
+
clearInterval(heartbeat);
|
|
797
|
+
activitySubscribers.delete(res);
|
|
798
|
+
});
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
439
801
|
// Health check
|
|
440
802
|
if (url.pathname === '/health' && method === 'GET') {
|
|
441
803
|
sendJson(res, 200, {
|
|
@@ -445,16 +807,77 @@ async function handleHttp(req, res) {
|
|
|
445
807
|
prekeyEntries: stmts.countPrekeys.get().cnt,
|
|
446
808
|
pushRegistrations: stmts.countPush.get().cnt,
|
|
447
809
|
mediaEntries: stmts.countMedia.get().cnt,
|
|
810
|
+
opkEntries: stmts.countOpks.get().cnt,
|
|
811
|
+
archiveEntries: stmts.countArchives.get().cnt,
|
|
812
|
+
federationPeersConfigured: federation?.stats.peersConfigured ?? 0,
|
|
813
|
+
federationPeersConnected: federation?.connectedPeerCount() ?? 0,
|
|
448
814
|
});
|
|
449
815
|
return;
|
|
450
816
|
}
|
|
817
|
+
// Prometheus-format metrics. Includes gauges (current state) and
|
|
818
|
+
// counters (cumulative since boot). Not rate-limited so a scraper
|
|
819
|
+
// can poll on its own schedule. Operators wanting to keep this
|
|
820
|
+
// private should gate it at the reverse proxy.
|
|
821
|
+
if (url.pathname === '/metrics' && method === 'GET') {
|
|
822
|
+
const lines = [];
|
|
823
|
+
const emit = (name, help, type, value, labels) => {
|
|
824
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
825
|
+
lines.push(`# TYPE ${name} ${type}`);
|
|
826
|
+
const labelStr = labels
|
|
827
|
+
? `{${Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',')}}`
|
|
828
|
+
: '';
|
|
829
|
+
lines.push(`${name}${labelStr} ${value}`);
|
|
830
|
+
};
|
|
831
|
+
const emitLabeled = (name, help, type, labelName, values) => {
|
|
832
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
833
|
+
lines.push(`# TYPE ${name} ${type}`);
|
|
834
|
+
for (const [label, value] of Object.entries(values)) {
|
|
835
|
+
lines.push(`${name}{${labelName}="${label}"} ${value}`);
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
// Uptime
|
|
839
|
+
emit('meshwhisper_uptime_seconds', 'Seconds since this node started', 'gauge', Math.floor((Date.now() - NODE_STARTED_AT_MS) / 1000));
|
|
840
|
+
// Live counts (gauges)
|
|
841
|
+
emit('meshwhisper_clients_connected', 'Currently connected WebSocket clients', 'gauge', clientsByHash.size);
|
|
842
|
+
emit('meshwhisper_stored_blobs', 'Encrypted blobs currently queued for offline delivery', 'gauge', stmts.countBlobs.get().cnt);
|
|
843
|
+
emit('meshwhisper_prekey_entries', 'Registered prekey-bundle entries in the directory', 'gauge', stmts.countPrekeys.get().cnt);
|
|
844
|
+
emit('meshwhisper_push_registrations', 'Active push-notification registrations', 'gauge', stmts.countPush.get().cnt);
|
|
845
|
+
emit('meshwhisper_media_entries', 'Encrypted media blobs currently stored', 'gauge', stmts.countMedia.get().cnt);
|
|
846
|
+
emit('meshwhisper_opk_entries', 'Unused one-time prekeys in the pool', 'gauge', stmts.countOpks.get().cnt);
|
|
847
|
+
emit('meshwhisper_archive_entries', 'Stored per-identity encrypted archives', 'gauge', stmts.countArchives.get().cnt);
|
|
848
|
+
// Counters
|
|
849
|
+
emit('meshwhisper_http_requests_total', 'Total HTTP requests served since startup', 'counter', metrics.httpRequestsTotal);
|
|
850
|
+
emitLabeled('meshwhisper_http_responses_total', 'HTTP responses by status family (plus 429 broken out)', 'counter', 'status', metrics.httpStatus);
|
|
851
|
+
emitLabeled('meshwhisper_rate_limit_rejections_total', 'Rate-limit (429) rejections by bucket', 'counter', 'bucket', metrics.rateLimitRejections);
|
|
852
|
+
emit('meshwhisper_websocket_connections_total', 'Total WebSocket connections accepted since startup', 'counter', metrics.websocketConnectionsTotal);
|
|
853
|
+
// Federation (zeroes when federation is dormant)
|
|
854
|
+
emit('meshwhisper_federation_peers_configured', 'Federation peers in the allow-list', 'gauge', federation?.stats.peersConfigured ?? 0);
|
|
855
|
+
emit('meshwhisper_federation_peers_connected', 'Federation peers with an established handshake', 'gauge', federation?.connectedPeerCount() ?? 0);
|
|
856
|
+
emit('meshwhisper_federation_forwards_sent_total', 'PacketForward frames sent to peers', 'counter', federation?.stats.forwardsSentTotal ?? 0);
|
|
857
|
+
emit('meshwhisper_federation_forwards_received_total', 'PacketForward frames received from peers', 'counter', federation?.stats.forwardsReceivedTotal ?? 0);
|
|
858
|
+
emit('meshwhisper_federation_delivered_locally_total', 'Federation packets delivered to a connected local client', 'counter', federation?.stats.deliveredLocallyTotal ?? 0);
|
|
859
|
+
emit('meshwhisper_federation_stored_locally_total', 'Federation packets stored for a local offline device', 'counter', federation?.stats.storedLocallyTotal ?? 0);
|
|
860
|
+
emit('meshwhisper_federation_forwarded_onward_total', 'Federation packets forwarded to further peers', 'counter', federation?.stats.forwardedOnwardTotal ?? 0);
|
|
861
|
+
emit('meshwhisper_federation_drops_duplicate_total', 'Federation packets dropped by the packet-id cache', 'counter', federation?.stats.dropsDuplicateTotal ?? 0);
|
|
862
|
+
emit('meshwhisper_federation_drops_ttl_total', 'Federation packets dropped by hop-count exhaustion', 'counter', federation?.stats.dropsTtlTotal ?? 0);
|
|
863
|
+
emit('meshwhisper_federation_handshake_failures_total', 'Failed federation handshakes', 'counter', federation?.stats.handshakeFailuresTotal ?? 0);
|
|
864
|
+
emit('meshwhisper_federation_drops_rate_limited_total', 'Federation frames dropped by per-peer rate limiting', 'counter', federation?.stats.dropsRateLimitedTotal ?? 0);
|
|
865
|
+
emit('meshwhisper_federation_handshake_rejections_blocked_total', 'Handshakes rejected by the blocklist', 'counter', federation?.stats.handshakeRejectionsBlockedTotal ?? 0);
|
|
866
|
+
const body = lines.join('\n') + '\n';
|
|
867
|
+
res.writeHead(200, {
|
|
868
|
+
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
869
|
+
'Content-Length': Buffer.byteLength(body),
|
|
870
|
+
'Access-Control-Allow-Origin': '*',
|
|
871
|
+
});
|
|
872
|
+
res.end(body);
|
|
873
|
+
recordHttpStatus(200);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
451
876
|
// Register prekey bundle
|
|
452
|
-
// POST /directory { namespace, publicKey, bundle }
|
|
877
|
+
// POST /directory { namespace, publicKey, bundle, username? }
|
|
453
878
|
if (url.pathname === '/directory' && method === 'POST') {
|
|
454
|
-
if (
|
|
455
|
-
sendJson(res, 429, { error: 'Too many requests' });
|
|
879
|
+
if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
|
|
456
880
|
return;
|
|
457
|
-
}
|
|
458
881
|
let body;
|
|
459
882
|
try {
|
|
460
883
|
body = JSON.parse(await parseBody(req));
|
|
@@ -470,35 +893,254 @@ async function handleHttp(req, res) {
|
|
|
470
893
|
sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey, bundle' });
|
|
471
894
|
return;
|
|
472
895
|
}
|
|
473
|
-
|
|
896
|
+
let username = null;
|
|
897
|
+
if (typeof body.username === 'string' && body.username) {
|
|
898
|
+
username = validateUsername(body.username);
|
|
899
|
+
if (!username) {
|
|
900
|
+
sendJson(res, 400, { error: 'Invalid username: 3–30 chars, a–z 0–9 _ -' });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
let transferAuth = null;
|
|
905
|
+
if (body.transferAuth && typeof body.transferAuth === 'object') {
|
|
906
|
+
const ta = body.transferAuth;
|
|
907
|
+
if (typeof ta.fromPublicKey === 'string' &&
|
|
908
|
+
typeof ta.expiresAt === 'number' &&
|
|
909
|
+
typeof ta.signature === 'string') {
|
|
910
|
+
transferAuth = {
|
|
911
|
+
fromPublicKey: ta.fromPublicKey,
|
|
912
|
+
expiresAt: ta.expiresAt,
|
|
913
|
+
signature: ta.signature,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
sendJson(res, 400, { error: 'Invalid transferAuth shape' });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const result = registerPrekeyTx(namespace, publicKey, bundle, username, transferAuth);
|
|
922
|
+
if (result === 'username_taken') {
|
|
923
|
+
sendJson(res, 409, {
|
|
924
|
+
error: 'Username already claimed by a different identity in this namespace',
|
|
925
|
+
});
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (result === 'invalid_transfer_auth') {
|
|
929
|
+
sendJson(res, 403, {
|
|
930
|
+
error: 'transferAuth invalid: signature, expiry, or sender mismatch',
|
|
931
|
+
});
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
474
934
|
sendJson(res, 200, { ok: true });
|
|
475
935
|
return;
|
|
476
936
|
}
|
|
937
|
+
// Set or read namespace policy
|
|
938
|
+
// POST /namespace-policy { namespace, usernamePolicy }
|
|
939
|
+
// First writer wins. Once set, the policy is sticky. Re-POSTing the
|
|
940
|
+
// same value returns 200; a different value returns 409.
|
|
941
|
+
// GET /namespace-policy?namespace=
|
|
942
|
+
// Returns the effective policy. Falls back to defaults when no row.
|
|
943
|
+
if (url.pathname === '/namespace-policy' && method === 'POST') {
|
|
944
|
+
if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
|
|
945
|
+
return;
|
|
946
|
+
let body;
|
|
947
|
+
try {
|
|
948
|
+
body = JSON.parse(await parseBody(req));
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const { namespace, usernamePolicy } = body;
|
|
955
|
+
if (typeof namespace !== 'string' || !namespace) {
|
|
956
|
+
sendJson(res, 400, { error: 'Missing required field: namespace' });
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (usernamePolicy !== 'signed-transfer' && usernamePolicy !== 'last-writer-wins') {
|
|
960
|
+
sendJson(res, 400, {
|
|
961
|
+
error: "usernamePolicy must be 'signed-transfer' or 'last-writer-wins'",
|
|
962
|
+
});
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
stmts.insertNamespacePolicy.run(namespace, usernamePolicy, Date.now());
|
|
966
|
+
const current = getNamespaceUsernamePolicy(namespace);
|
|
967
|
+
if (current !== usernamePolicy) {
|
|
968
|
+
sendJson(res, 409, {
|
|
969
|
+
error: 'Namespace policy already set to a different value',
|
|
970
|
+
currentPolicy: current,
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
sendJson(res, 200, { ok: true, usernamePolicy: current });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (url.pathname === '/namespace-policy' && method === 'GET') {
|
|
978
|
+
if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
|
|
979
|
+
return;
|
|
980
|
+
const namespace = url.searchParams.get('namespace');
|
|
981
|
+
if (!namespace) {
|
|
982
|
+
sendJson(res, 400, { error: 'Missing query param: namespace' });
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
sendJson(res, 200, {
|
|
986
|
+
namespace,
|
|
987
|
+
usernamePolicy: getNamespaceUsernamePolicy(namespace),
|
|
988
|
+
});
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
477
991
|
// Lookup prekey bundle
|
|
478
992
|
// GET /directory?namespace=&publicKey=
|
|
993
|
+
// GET /directory?namespace=&username=alice
|
|
479
994
|
if (url.pathname === '/directory' && method === 'GET') {
|
|
995
|
+
// Username/publicKey lookup is the natural endpoint for an enumeration
|
|
996
|
+
// attempt — bucket separately from writes so a busy lookup workload
|
|
997
|
+
// doesn't starve registrations and vice versa.
|
|
998
|
+
if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
|
|
999
|
+
return;
|
|
1000
|
+
const namespace = url.searchParams.get('namespace');
|
|
1001
|
+
const usernameParam = url.searchParams.get('username');
|
|
1002
|
+
const publicKeyParam = url.searchParams.get('publicKey');
|
|
1003
|
+
if (!namespace) {
|
|
1004
|
+
sendJson(res, 400, { error: 'Missing query param: namespace' });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (usernameParam) {
|
|
1008
|
+
const username = validateUsername(usernameParam);
|
|
1009
|
+
if (!username) {
|
|
1010
|
+
sendJson(res, 400, { error: 'Invalid username' });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const result = lookupPrekeyByUsername(namespace, username);
|
|
1014
|
+
if (!result) {
|
|
1015
|
+
sendJson(res, 404, { error: 'User not found' });
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
sendJson(res, 200, { bundle: result.bundle, publicKey: result.publicKey, username });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (!publicKeyParam) {
|
|
1022
|
+
sendJson(res, 400, { error: 'Missing query param: publicKey or username' });
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const result = lookupPrekeyByPublicKey(namespace, publicKeyParam);
|
|
1026
|
+
if (!result) {
|
|
1027
|
+
sendJson(res, 404, { error: 'Prekey bundle not found' });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
sendJson(res, 200, { bundle: result.bundle, ...(result.username ? { username: result.username } : {}) });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
// Upload one-time pre-keys for a user
|
|
1034
|
+
// POST /opks { namespace, publicKey, opks: string[] } (base64 public keys)
|
|
1035
|
+
if (url.pathname === '/opks' && method === 'POST') {
|
|
1036
|
+
if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
|
|
1037
|
+
return;
|
|
1038
|
+
let body;
|
|
1039
|
+
try {
|
|
1040
|
+
body = JSON.parse(await parseBody(req));
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const { namespace, publicKey, opks } = body;
|
|
1047
|
+
if (typeof namespace !== 'string' || !namespace ||
|
|
1048
|
+
typeof publicKey !== 'string' || !publicKey ||
|
|
1049
|
+
!Array.isArray(opks) || opks.length === 0) {
|
|
1050
|
+
sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey, opks[]' });
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const MAX_OPKS_PER_UPLOAD = 20;
|
|
1054
|
+
const MAX_OPKS_PER_IDENTITY = 100;
|
|
1055
|
+
const batch = opks.slice(0, MAX_OPKS_PER_UPLOAD).filter((o) => typeof o === 'string' && o.length > 0);
|
|
1056
|
+
const identityKey = directoryKey(namespace, publicKey);
|
|
1057
|
+
const existing = stmts.countOpksForKey.get(identityKey).cnt;
|
|
1058
|
+
const canStore = Math.max(0, MAX_OPKS_PER_IDENTITY - existing);
|
|
1059
|
+
const toStore = batch.slice(0, canStore);
|
|
1060
|
+
const now = Date.now();
|
|
1061
|
+
for (const opk of toStore) {
|
|
1062
|
+
stmts.insertOpk.run(identityKey, opk, now);
|
|
1063
|
+
}
|
|
1064
|
+
sendJson(res, 200, { ok: true, stored: toStore.length });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
// Purge all OPKs for a (namespace, publicKey) pair.
|
|
1068
|
+
// DELETE /opks { namespace, publicKey }
|
|
1069
|
+
// Authorization: Bearer <token>
|
|
1070
|
+
//
|
|
1071
|
+
// TOFU auth: first DELETE for a given publicKey establishes the auth hash;
|
|
1072
|
+
// subsequent DELETEs must present a token whose SHA-256 matches the stored
|
|
1073
|
+
// hash. Identity_pubkey is cross-namespace so the same person purging
|
|
1074
|
+
// their pool in different namespaces uses the same token.
|
|
1075
|
+
//
|
|
1076
|
+
// Used by the SDK's one-shot OPK-pool migration to clear zombie entries
|
|
1077
|
+
// left by the pre-fix bulk re-upload bug. Apps don't need to call this
|
|
1078
|
+
// directly — the SDK runs it on first init after the fix.
|
|
1079
|
+
if (url.pathname === '/opks' && method === 'DELETE') {
|
|
1080
|
+
if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
|
|
1081
|
+
return;
|
|
1082
|
+
const auth = req.headers['authorization'];
|
|
1083
|
+
if (!auth || !auth.startsWith('Bearer ')) {
|
|
1084
|
+
sendJson(res, 401, { error: 'Missing bearer token' });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const token = auth.slice('Bearer '.length).trim();
|
|
1088
|
+
let body;
|
|
1089
|
+
try {
|
|
1090
|
+
body = JSON.parse(await parseBody(req));
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const { namespace, publicKey } = body;
|
|
1097
|
+
if (typeof namespace !== 'string' || !namespace || typeof publicKey !== 'string' || !publicKey) {
|
|
1098
|
+
sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey' });
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
const incomingHash = nodeCrypto.createHash('sha256').update(token).digest('hex');
|
|
1102
|
+
// TOFU: insert-or-ignore establishes the hash on first call. Subsequent
|
|
1103
|
+
// calls succeed only if the hash matches what's already stored.
|
|
1104
|
+
stmts.insertOpkAuthHash.run(publicKey, incomingHash, Date.now());
|
|
1105
|
+
const existing = stmts.getOpkAuthHash.get(publicKey);
|
|
1106
|
+
if (!existing || existing.auth_hash !== incomingHash) {
|
|
1107
|
+
sendJson(res, 403, { error: 'Invalid OPK pool token' });
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const result = stmts.deleteOpksForKey.run(directoryKey(namespace, publicKey));
|
|
1111
|
+
sendJson(res, 200, { ok: true, deleted: result.changes });
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
// Claim one one-time pre-key for a user (atomic — removes it from the pool)
|
|
1115
|
+
// GET /opks/claim?namespace=&publicKey=
|
|
1116
|
+
if (url.pathname === '/opks/claim' && method === 'GET') {
|
|
1117
|
+
// OPK claim is a write-like operation (each call atomically consumes a
|
|
1118
|
+
// one-time prekey from the pool). Bucket with the other write endpoints
|
|
1119
|
+
// so the directory budget gates abuse — an attacker draining a victim's
|
|
1120
|
+
// OPK pool to force fallback to the signed-prekey-only handshake is the
|
|
1121
|
+
// primary risk this addresses.
|
|
1122
|
+
if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
|
|
1123
|
+
return;
|
|
480
1124
|
const namespace = url.searchParams.get('namespace');
|
|
481
1125
|
const publicKey = url.searchParams.get('publicKey');
|
|
482
1126
|
if (!namespace || !publicKey) {
|
|
483
1127
|
sendJson(res, 400, { error: 'Missing query params: namespace, publicKey' });
|
|
484
1128
|
return;
|
|
485
1129
|
}
|
|
486
|
-
const
|
|
487
|
-
if (!
|
|
488
|
-
sendJson(res, 404, { error: '
|
|
1130
|
+
const opk = claimOpkTx(directoryKey(namespace, publicKey));
|
|
1131
|
+
if (!opk) {
|
|
1132
|
+
sendJson(res, 404, { error: 'No one-time pre-keys available' });
|
|
489
1133
|
return;
|
|
490
1134
|
}
|
|
491
|
-
sendJson(res, 200, {
|
|
1135
|
+
sendJson(res, 200, { opk });
|
|
492
1136
|
return;
|
|
493
1137
|
}
|
|
494
1138
|
// Upload encrypted media blob
|
|
495
1139
|
// POST /media (binary body, Content-Length required)
|
|
496
1140
|
// Returns: { id, url, expiresAt }
|
|
497
1141
|
if (url.pathname === '/media' && method === 'POST') {
|
|
498
|
-
if (
|
|
499
|
-
sendJson(res, 429, { error: 'Too many requests' });
|
|
1142
|
+
if (rateLimited(req, res, 'media', RATE_LIMIT_MEDIA))
|
|
500
1143
|
return;
|
|
501
|
-
}
|
|
502
1144
|
const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
|
|
503
1145
|
if (contentLength > MAX_MEDIA_SIZE) {
|
|
504
1146
|
sendJson(res, 413, { error: `Payload too large (max ${MAX_MEDIA_SIZE} bytes)` });
|
|
@@ -535,6 +1177,8 @@ async function handleHttp(req, res) {
|
|
|
535
1177
|
// GET /media/:id
|
|
536
1178
|
const mediaMatch = url.pathname.match(/^\/media\/([0-9a-f]{32})$/);
|
|
537
1179
|
if (mediaMatch && method === 'GET') {
|
|
1180
|
+
if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
|
|
1181
|
+
return;
|
|
538
1182
|
const data = fetchMedia(mediaMatch[1]);
|
|
539
1183
|
if (!data) {
|
|
540
1184
|
sendJson(res, 404, { error: 'Media not found or expired' });
|
|
@@ -549,6 +1193,78 @@ async function handleHttp(req, res) {
|
|
|
549
1193
|
res.end(data);
|
|
550
1194
|
return;
|
|
551
1195
|
}
|
|
1196
|
+
// Upload encrypted archive
|
|
1197
|
+
// PUT /archive/:peerId Authorization: Bearer <token>
|
|
1198
|
+
const archivePutMatch = url.pathname.match(/^\/archive\/([0-9a-f]{64})$/);
|
|
1199
|
+
if (archivePutMatch && method === 'PUT') {
|
|
1200
|
+
if (rateLimited(req, res, 'archive', RATE_LIMIT_ARCHIVE))
|
|
1201
|
+
return;
|
|
1202
|
+
const peerId = archivePutMatch[1];
|
|
1203
|
+
const authHeader = req.headers['authorization'] ?? '';
|
|
1204
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
1205
|
+
if (!token) {
|
|
1206
|
+
sendJson(res, 401, { error: 'Missing Authorization header' });
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
|
|
1210
|
+
if (contentLength > MAX_ARCHIVE_SIZE) {
|
|
1211
|
+
sendJson(res, 413, { error: `Archive too large (max ${MAX_ARCHIVE_SIZE} bytes)` });
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const chunks = [];
|
|
1215
|
+
let received = 0;
|
|
1216
|
+
let aborted = false;
|
|
1217
|
+
await new Promise((resolve, reject) => {
|
|
1218
|
+
req.on('data', (chunk) => {
|
|
1219
|
+
received += chunk.length;
|
|
1220
|
+
if (received > MAX_ARCHIVE_SIZE) {
|
|
1221
|
+
aborted = true;
|
|
1222
|
+
req.destroy();
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
chunks.push(chunk);
|
|
1226
|
+
});
|
|
1227
|
+
req.on('end', resolve);
|
|
1228
|
+
req.on('error', reject);
|
|
1229
|
+
});
|
|
1230
|
+
if (aborted) {
|
|
1231
|
+
sendJson(res, 413, { error: `Archive too large (max ${MAX_ARCHIVE_SIZE} bytes)` });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const data = Buffer.concat(chunks);
|
|
1235
|
+
const incomingHash = nodeCrypto.createHash('sha256').update(token).digest('hex');
|
|
1236
|
+
// First write: INSERT OR IGNORE sets the auth_hash. The upsert only
|
|
1237
|
+
// succeeds when auth_hash matches, preventing overwrites by other parties.
|
|
1238
|
+
stmts.insertArchiveFirstTime.run(peerId, incomingHash, data, Date.now(), data.length);
|
|
1239
|
+
const existing = stmts.getArchiveAuthHash.get(peerId);
|
|
1240
|
+
if (!existing || existing.auth_hash !== incomingHash) {
|
|
1241
|
+
sendJson(res, 403, { error: 'Invalid archive token' });
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
stmts.upsertArchive.run(peerId, incomingHash, data, Date.now(), data.length);
|
|
1245
|
+
sendJson(res, 200, { ok: true, size: data.length });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
// Download encrypted archive
|
|
1249
|
+
// GET /archive/:peerId (no auth — content is client-encrypted)
|
|
1250
|
+
const archiveGetMatch = url.pathname.match(/^\/archive\/([0-9a-f]{64})$/);
|
|
1251
|
+
if (archiveGetMatch && method === 'GET') {
|
|
1252
|
+
if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
|
|
1253
|
+
return;
|
|
1254
|
+
const row = stmts.getArchiveData.get(archiveGetMatch[1]);
|
|
1255
|
+
if (!row) {
|
|
1256
|
+
sendJson(res, 404, { error: 'Archive not found' });
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
res.writeHead(200, {
|
|
1260
|
+
'Content-Type': 'application/octet-stream',
|
|
1261
|
+
'Content-Length': row.data.length,
|
|
1262
|
+
'Access-Control-Allow-Origin': '*',
|
|
1263
|
+
'Cache-Control': 'no-store',
|
|
1264
|
+
});
|
|
1265
|
+
res.end(row.data);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
552
1268
|
sendJson(res, 404, { error: 'Not found' });
|
|
553
1269
|
}
|
|
554
1270
|
// ============================================================
|
|
@@ -560,11 +1276,89 @@ const httpServer = http.createServer((req, res) => {
|
|
|
560
1276
|
res.writeHead(500).end();
|
|
561
1277
|
});
|
|
562
1278
|
});
|
|
1279
|
+
// ============================================================
|
|
1280
|
+
// Federation (docs/federation.md v1)
|
|
1281
|
+
//
|
|
1282
|
+
// Dormant unless a peers file with at least one entry exists. The
|
|
1283
|
+
// classifyLocal callback answers "can this packet be handled here?":
|
|
1284
|
+
// - a connected client holds the destHash → deliver ('delivered')
|
|
1285
|
+
// - a push registration exists for the destHash → store+wake ('stored';
|
|
1286
|
+
// the device is homed here, currently offline)
|
|
1287
|
+
// - neither → 'unknown' (forward onward)
|
|
1288
|
+
// ============================================================
|
|
1289
|
+
const FEDERATION_KEY_FILE = process.env.FEDERATION_KEY_FILE
|
|
1290
|
+
?? path.join(path.dirname(DB_PATH), 'federation-key.json');
|
|
1291
|
+
const FEDERATION_PEERS_FILE = process.env.FEDERATION_PEERS_FILE
|
|
1292
|
+
?? path.join(path.dirname(DB_PATH), 'federation-peers.json');
|
|
1293
|
+
const FEDERATION_BLOCKLIST_FILE = process.env.FEDERATION_BLOCKLIST_FILE
|
|
1294
|
+
?? path.join(path.dirname(DB_PATH), 'federation-blocklist.json');
|
|
1295
|
+
// FEDERATION_MODE: 'off' | 'allowlist' | 'open'.
|
|
1296
|
+
// off — dormant regardless of peers file
|
|
1297
|
+
// allowlist — only pre-approved pubkeys; v1 behavior
|
|
1298
|
+
// open — accept any peer completing the handshake (recommended once
|
|
1299
|
+
// you're comfortable; per-peer rate limiting is the abuse
|
|
1300
|
+
// boundary). Peers file becomes the outbound bootstrap list.
|
|
1301
|
+
// Unset (back-compat): allowlist when the peers file has entries, else off.
|
|
1302
|
+
const federationPeersConfig = loadPeersConfig(FEDERATION_PEERS_FILE);
|
|
1303
|
+
const FEDERATION_MODE_RAW = (process.env.FEDERATION_MODE ?? '').toLowerCase();
|
|
1304
|
+
const federationMode = FEDERATION_MODE_RAW === 'open' ? 'open'
|
|
1305
|
+
: FEDERATION_MODE_RAW === 'allowlist' ? 'allowlist'
|
|
1306
|
+
: FEDERATION_MODE_RAW === 'off' ? 'off'
|
|
1307
|
+
: (federationPeersConfig.length > 0 ? 'allowlist' : 'off');
|
|
1308
|
+
const FEDERATION_MAX_PEERS = parseInt(process.env.FEDERATION_MAX_PEERS ?? '64', 10);
|
|
1309
|
+
const FEDERATION_RATE_LIMIT = parseInt(process.env.FEDERATION_RATE_LIMIT ?? '6000', 10); // frames/min/peer
|
|
1310
|
+
const federation = federationMode !== 'off'
|
|
1311
|
+
? new FederationManager({
|
|
1312
|
+
key: loadOrCreateFederationKey(FEDERATION_KEY_FILE),
|
|
1313
|
+
peers: federationPeersConfig,
|
|
1314
|
+
mode: federationMode,
|
|
1315
|
+
blockedPubkeys: loadBlocklist(FEDERATION_BLOCKLIST_FILE),
|
|
1316
|
+
maxPeers: FEDERATION_MAX_PEERS,
|
|
1317
|
+
rateLimitPerMin: FEDERATION_RATE_LIMIT,
|
|
1318
|
+
classifyLocal: (packet) => {
|
|
1319
|
+
const destHash = readDestHash(packet);
|
|
1320
|
+
if (!destHash)
|
|
1321
|
+
return 'delivered'; // malformed — swallow, don't propagate
|
|
1322
|
+
const recipient = clientsByHash.get(destHash);
|
|
1323
|
+
if (recipient && recipient.readyState === WebSocket.OPEN) {
|
|
1324
|
+
// Same store-then-deliver race protection as the client path.
|
|
1325
|
+
const blobId = storeBlob(destHash, packet);
|
|
1326
|
+
recipient.send(packet, { binary: true }, (err) => {
|
|
1327
|
+
if (!err)
|
|
1328
|
+
stmts.deleteBlob.run(blobId);
|
|
1329
|
+
});
|
|
1330
|
+
bumpActivity('fwd');
|
|
1331
|
+
return 'delivered';
|
|
1332
|
+
}
|
|
1333
|
+
const pushReg = getPushRegistration(destHash);
|
|
1334
|
+
if (pushReg) {
|
|
1335
|
+
storeBlob(destHash, packet);
|
|
1336
|
+
notifyPush(destHash, pushReg);
|
|
1337
|
+
bumpActivity('wake');
|
|
1338
|
+
return 'stored';
|
|
1339
|
+
}
|
|
1340
|
+
return 'unknown';
|
|
1341
|
+
},
|
|
1342
|
+
})
|
|
1343
|
+
: null;
|
|
1344
|
+
if (federation) {
|
|
1345
|
+
federation.start();
|
|
1346
|
+
console.log(`[federation] mode=${federationMode}, ${federationPeersConfig.length} configured peer(s), max ${FEDERATION_MAX_PEERS}`);
|
|
1347
|
+
}
|
|
563
1348
|
const wss = new WebSocketServer({ noServer: true });
|
|
564
1349
|
wss.on('connection', (ws) => {
|
|
1350
|
+
metrics.websocketConnectionsTotal++;
|
|
565
1351
|
handleWebSocketConnection(ws);
|
|
566
1352
|
});
|
|
567
1353
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
1354
|
+
// Federation peers negotiate the meshwhisper-federation.v1 subprotocol;
|
|
1355
|
+
// everything else is a regular client-relay connection.
|
|
1356
|
+
const offered = (req.headers['sec-websocket-protocol'] ?? '')
|
|
1357
|
+
.split(',').map((s) => s.trim());
|
|
1358
|
+
if (federation && offered.includes(FEDERATION_SUBPROTOCOL)) {
|
|
1359
|
+
federation.handleUpgrade(req, socket, head);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
568
1362
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
569
1363
|
wss.emit('connection', ws, req);
|
|
570
1364
|
});
|
|
@@ -576,11 +1370,16 @@ const pruneInterval = setInterval(() => {
|
|
|
576
1370
|
pruneRateLimitState();
|
|
577
1371
|
}, PRUNE_INTERVAL_MS);
|
|
578
1372
|
pruneInterval.unref();
|
|
579
|
-
|
|
1373
|
+
// Default to '::' (IPv6 wildcard) which also accepts IPv4 on Linux (bindv6only=0).
|
|
1374
|
+
// Set LISTEN_HOST=0.0.0.0 to restrict to IPv4 only.
|
|
1375
|
+
const LISTEN_HOST = process.env.LISTEN_HOST ?? '::';
|
|
1376
|
+
httpServer.listen(PORT, LISTEN_HOST, () => {
|
|
580
1377
|
console.log(`MeshWhisper Node listening on port ${PORT}`);
|
|
581
1378
|
console.log(` Relay: ws://localhost:${PORT}`);
|
|
582
1379
|
console.log(` Directory: http://localhost:${PORT}/directory`);
|
|
1380
|
+
console.log(` OPKs: http://localhost:${PORT}/opks`);
|
|
583
1381
|
console.log(` Media: http://localhost:${PORT}/media`);
|
|
1382
|
+
console.log(` Archive: http://localhost:${PORT}/archive/:peerId`);
|
|
584
1383
|
console.log(` Health: http://localhost:${PORT}/health`);
|
|
585
1384
|
console.log(` Database: ${DB_PATH}`);
|
|
586
1385
|
console.log(` Blob TTL: ${BLOB_TTL_HOURS}h`);
|