@walkhi/code-relax 0.1.0-beta.23 → 0.1.0-beta.25
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/bin/cleanup-accounts.mjs +63 -0
- package/dist/bin/self-relay-server.mjs +1 -1
- package/dist/src/self-relay/account-cleanup.mjs +30 -0
- package/dist/src/self-relay/admin-page.mjs +6 -12
- package/dist/src/self-relay/admin-server.mjs +17 -9
- package/dist/src/self-relay/client.mjs +45 -45
- package/dist/src/self-relay/connector.mjs +293 -289
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +158 -141
- package/dist/src/self-relay/privacy-page.mjs +26 -0
- package/dist/src/self-relay/server.mjs +183 -86
- package/dist/src/self-relay/usage-meter.mjs +141 -136
- package/dist/src/server.mjs +15 -13
- package/package.json +1 -1
|
@@ -1,22 +1,22 @@
|
|
|
1
|
+
import { privacyPage } from './privacy-page.mjs';
|
|
1
2
|
import http from 'node:http';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import { isIP } from 'node:net';
|
|
4
|
-
import { deviceStore, validId } from './device-store.mjs';
|
|
5
|
+
import { deviceStore, validId } from './device-store.mjs';
|
|
5
6
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
6
|
-
import { WebSocketServer } from 'ws';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
7
8
|
import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
8
9
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
9
10
|
import { validProbeSignal } from './p2p-probe.mjs';
|
|
10
11
|
import { relayAdminState } from './admin-state.mjs';
|
|
11
12
|
import { openRelayMysql } from './mysql-state.mjs';
|
|
12
|
-
import { adminPage } from './admin-page.mjs';
|
|
13
13
|
import { openEmailAccounts } from './email-accounts.mjs';
|
|
14
14
|
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
15
15
|
import { relayDiagnostics } from './diagnostics.mjs';
|
|
16
16
|
|
|
17
|
-
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
17
|
+
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
18
18
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
19
|
-
maxConnections =
|
|
19
|
+
maxConnections = 128, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
20
20
|
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
|
|
21
21
|
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
22
22
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
@@ -32,8 +32,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
32
32
|
if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
|
|
33
33
|
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
34
34
|
const meter = accounts ? usageMeter(database.pool) : null;
|
|
35
|
+
const cancelledAccountIds = new Set();
|
|
35
36
|
const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
|
|
36
37
|
? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
|
|
38
|
+
const adminAccessFile = '/etc/codex-relay/admin-access.json';
|
|
39
|
+
const adminAccessKey = fs.existsSync(adminAccessFile) ? JSON.parse(fs.readFileSync(adminAccessFile, 'utf8')).key : '';
|
|
40
|
+
if (adminAccessKey && !validSecret(adminAccessKey)) throw new Error('Invalid admin access key');
|
|
37
41
|
const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
|
|
38
42
|
if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
|
|
39
43
|
const store = database?.devices || deviceStore(stateFile);
|
|
@@ -42,23 +46,39 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
42
46
|
let activeRelaySessions = 0;
|
|
43
47
|
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
|
|
44
48
|
let commitTail = Promise.resolve();
|
|
49
|
+
function queueStore(operation) {
|
|
50
|
+
const pending = commitTail.then(operation);
|
|
51
|
+
commitTail = pending.catch(() => {});
|
|
52
|
+
return pending;
|
|
53
|
+
}
|
|
45
54
|
function commit(room, changes) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
return queueStore(async () => {
|
|
56
|
+
changes = typeof changes === 'function' ? changes(room) : changes;
|
|
57
|
+
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
58
|
+
if (room.durable) {
|
|
59
|
+
const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
|
|
60
|
+
await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
|
|
61
|
+
}
|
|
62
|
+
Object.assign(room, changes, { updatedAt: next.updatedAt });
|
|
54
63
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
}
|
|
65
|
+
function registerRoom(id, durable, credential) {
|
|
66
|
+
return queueStore(async () => {
|
|
67
|
+
// Resolve concurrent first connections before writing the device identity.
|
|
68
|
+
if (durable && rooms.has(id)) return rooms.get(id);
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
const record = { id, revision: 0, status: 'active', clients: [], createdAt: now, updatedAt: now,
|
|
71
|
+
...(durable ? { hostHash: digest(credential) } : {}) };
|
|
72
|
+
if (durable) await store.save(record);
|
|
73
|
+
const room = { ...record, durable, connections: new Map(), observers: new Map(), uploadClients: new Map() };
|
|
74
|
+
rooms.set(id, room);
|
|
75
|
+
return room;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function invitation(room) {
|
|
79
|
+
const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
|
|
80
|
+
return { code, expires: room.expires };
|
|
81
|
+
}
|
|
62
82
|
let listenUrl = '';
|
|
63
83
|
let adminTail = Promise.resolve();
|
|
64
84
|
function relayUrl(request) {
|
|
@@ -79,6 +99,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
79
99
|
});
|
|
80
100
|
return;
|
|
81
101
|
}
|
|
102
|
+
if (target.pathname === '/account/privacy' && request.method === 'GET') {
|
|
103
|
+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache',
|
|
104
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
105
|
+
'X-Content-Type-Options': 'nosniff' });
|
|
106
|
+
response.end(privacyPage); return;
|
|
107
|
+
}
|
|
82
108
|
if (accounts && target.pathname.startsWith('/account/')) {
|
|
83
109
|
void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
|
|
84
110
|
return;
|
|
@@ -132,6 +158,33 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
132
158
|
if (target.pathname === '/account/logout' && request.method === 'POST') {
|
|
133
159
|
accountReply(response, 200, await accounts.logout(bearer)); return;
|
|
134
160
|
}
|
|
161
|
+
if (target.pathname === '/account/me' && request.method === 'DELETE') {
|
|
162
|
+
const user = await accounts.current(bearer);
|
|
163
|
+
cancelledAccountIds.add(user.id);
|
|
164
|
+
try { await accounts.cancelAccount(bearer); }
|
|
165
|
+
catch (failure) { cancelledAccountIds.delete(user.id); throw failure; }
|
|
166
|
+
for (const room of rooms.values()) {
|
|
167
|
+
for (const [key, arm] of room.usageArms || []) if (arm.userId === user.id) room.usageArms.delete(key);
|
|
168
|
+
for (const [key, task] of room.usageTasks || []) if (task.userId === user.id) room.usageTasks.delete(key);
|
|
169
|
+
for (const [clientId, connection] of room.connections) {
|
|
170
|
+
if (connection.userId !== user.id) continue;
|
|
171
|
+
releaseRelay(connection); room.connections.delete(clientId);
|
|
172
|
+
if (room.host) send(room.host, { type: 'detach', session: connection.session });
|
|
173
|
+
connection.ws.close(4431, 'Account cancelled');
|
|
174
|
+
}
|
|
175
|
+
for (const [clientId, observer] of room.observers) {
|
|
176
|
+
if (observer.userId !== user.id) continue;
|
|
177
|
+
room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
|
|
178
|
+
}
|
|
179
|
+
for (const [clientId, upload] of room.uploadClients) {
|
|
180
|
+
if (upload.userId !== user.id) continue;
|
|
181
|
+
room.uploadClients.delete(clientId);
|
|
182
|
+
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
183
|
+
upload.ws.close(4431, 'Account cancelled');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
accountReply(response, 200, { ok: true }); return;
|
|
187
|
+
}
|
|
135
188
|
if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
|
|
136
189
|
accountReply(response, 404, { error: 'Account route not found' }); return;
|
|
137
190
|
}
|
|
@@ -149,26 +202,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
149
202
|
}
|
|
150
203
|
}
|
|
151
204
|
async function handleAdmin(request, response, target) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
|
|
161
|
-
let email;
|
|
162
|
-
if (!keyAccepted) {
|
|
163
|
-
try { email = (await accounts.current(token)).email; }
|
|
164
|
-
catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
|
|
165
|
-
const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
|
|
166
|
-
if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
|
|
167
|
-
}
|
|
168
|
-
if (target.pathname === '/admin/api/session' && request.method === 'GET') {
|
|
169
|
-
adminReply(response, 200, { email }); return;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
205
|
+
const accessKey = request.headers['x-code-relax-admin-key'];
|
|
206
|
+
const internalAccess = adminAccessKey && validSecret(accessKey) &&
|
|
207
|
+
timingSafeEqual(Buffer.from(accessKey), Buffer.from(adminAccessKey));
|
|
208
|
+
const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
|
|
209
|
+
const diagnosticRoute = ['/admin/api/usage-diagnostics', '/admin/api/usage-diagnostics/drain'].includes(target.pathname);
|
|
210
|
+
const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
|
|
211
|
+
timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
|
|
212
|
+
if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
|
|
172
213
|
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
173
214
|
const state = adminState.read();
|
|
174
215
|
adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
|
|
@@ -188,14 +229,18 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
188
229
|
}
|
|
189
230
|
adminReply(response, 200, await usageReport(database.pool, page, view)); return;
|
|
190
231
|
}
|
|
232
|
+
const diagnosticSource = target.searchParams.get('source') || 'usage';
|
|
233
|
+
if (target.pathname.startsWith('/admin/api/usage-diagnostics') && !['usage', 'account-cleanup'].includes(diagnosticSource)) {
|
|
234
|
+
adminReply(response, 400, { error: 'Invalid diagnostic source' }); return;
|
|
235
|
+
}
|
|
191
236
|
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
|
|
192
|
-
if (request.method === 'GET') adminReply(response, 200, await diagnostics.read(
|
|
193
|
-
else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear(
|
|
237
|
+
if (request.method === 'GET') adminReply(response, 200, await diagnostics.read(diagnosticSource));
|
|
238
|
+
else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear(diagnosticSource));
|
|
194
239
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
195
240
|
return;
|
|
196
241
|
}
|
|
197
242
|
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
|
|
198
|
-
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(
|
|
243
|
+
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(diagnosticSource));
|
|
199
244
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
200
245
|
return;
|
|
201
246
|
}
|
|
@@ -262,23 +307,26 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
262
307
|
serverNoContextTakeover: true, clientNoContextTakeover: true,
|
|
263
308
|
threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
|
|
264
309
|
} });
|
|
265
|
-
let admissionStart = Date.now(), admissions = 0;
|
|
266
|
-
server.on('upgrade', (request, socket, head) => {
|
|
310
|
+
let admissionStart = Date.now(), admissions = 0;
|
|
311
|
+
server.on('upgrade', (request, socket, head) => {
|
|
267
312
|
if (request.url !== '/relay' || (request.headers.origin && request.headers.origin !== 'null' && !nativeOrigins.includes(request.headers.origin))) {
|
|
268
313
|
log({ event: 'upgrade-rejected', reason: request.url !== '/relay' ? 'path' : 'origin' });
|
|
269
|
-
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
|
|
270
|
-
}
|
|
271
|
-
if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
|
|
272
|
-
if (wss.clients.size >= maxConnections || ++admissions >
|
|
273
|
-
socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
|
|
274
|
-
}
|
|
275
|
-
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
|
|
276
|
-
});
|
|
314
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
|
|
315
|
+
}
|
|
316
|
+
if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
|
|
317
|
+
if (wss.clients.size >= maxConnections || ++admissions > 240) {
|
|
318
|
+
socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
|
|
319
|
+
}
|
|
320
|
+
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
|
|
321
|
+
});
|
|
277
322
|
wss.on('connection', ws => {
|
|
278
323
|
let role, room, session, clientId, alive = true;
|
|
279
|
-
const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
|
|
280
|
-
ws.on('pong', () => { alive = true; });
|
|
281
|
-
const heartbeat = setInterval(() => {
|
|
324
|
+
const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
|
|
325
|
+
ws.on('pong', () => { alive = true; });
|
|
326
|
+
const heartbeat = setInterval(() => {
|
|
327
|
+
sampleInboundQueue();
|
|
328
|
+
if (!alive) ws.terminate(); else { alive = false; ws.ping(); }
|
|
329
|
+
}, 30000);
|
|
282
330
|
const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
|
|
283
331
|
const queueClientMessage = (connection, message, pause = 0) => {
|
|
284
332
|
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
@@ -292,10 +340,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
292
340
|
send(connection.ws, message);
|
|
293
341
|
}).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
|
|
294
342
|
};
|
|
295
|
-
ws.on('error', () => {}); // close owns routing cleanup.
|
|
343
|
+
ws.on('error', () => {}); // close owns routing cleanup.
|
|
296
344
|
ws.on('close', (code, reason) => {
|
|
297
|
-
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
298
|
-
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
345
|
+
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
346
|
+
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
299
347
|
if (role === 'host' && room?.host === ws) {
|
|
300
348
|
for (const task of room.usageTasks?.values() || []) {
|
|
301
349
|
void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
|
|
@@ -303,10 +351,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
303
351
|
room.usageTasks?.clear(); room.usageArms?.clear();
|
|
304
352
|
room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
|
|
305
353
|
room.uploadHost?.close(4410, 'Computer offline');
|
|
306
|
-
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
|
|
354
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline');
|
|
307
355
|
for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
|
|
308
356
|
if (!room.durable) rooms.delete(room.id);
|
|
309
|
-
for (const connection of room.connections.values()) connection.ws.close(
|
|
357
|
+
for (const connection of room.connections.values()) connection.ws.close(
|
|
358
|
+
code === 4432 ? 4432 : 4410,
|
|
359
|
+
code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline; authorization retained');
|
|
310
360
|
} else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
311
361
|
const connection = room.connections.get(clientId);
|
|
312
362
|
releaseRelay(connection);
|
|
@@ -316,18 +366,57 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
316
366
|
room.observers.delete(clientId);
|
|
317
367
|
} else if (role === 'file-host' && room?.uploadHost === ws) {
|
|
318
368
|
room.uploadHost = undefined;
|
|
319
|
-
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
|
|
369
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'File receiver inbound queue limit exceeded' : 'File receiver offline');
|
|
320
370
|
} else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
|
|
321
371
|
room.uploadClients.delete(clientId);
|
|
322
372
|
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
|
|
323
373
|
}
|
|
324
|
-
});
|
|
374
|
+
});
|
|
325
375
|
let messageTail = Promise.resolve();
|
|
376
|
+
// Include the in-flight message: async database work must not permit an
|
|
377
|
+
// unbounded chain of closures retaining incoming payloads.
|
|
378
|
+
let pendingMessages = 0, pendingBytes = 0;
|
|
379
|
+
const maxPendingMessages = 128, maxPendingBytes = 16 * 1024 * 1024;
|
|
380
|
+
let sampleStarted = Date.now(), peakMessages = 0, peakBytes = 0, maxWaitMs = 0, receivedMessages = 0;
|
|
381
|
+
function sampleInboundQueue() {
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
if (now - sampleStarted < 60000) return;
|
|
384
|
+
// Summarize only intervals with an actual backlog; never log payloads or credentials.
|
|
385
|
+
if (peakMessages > 1) log({ event: 'inbound-queue-sample', role: role || 'unauthenticated',
|
|
386
|
+
windowMs: now - sampleStarted, receivedMessages, peakMessages, peakBytes, maxWaitMs,
|
|
387
|
+
pendingMessages, pendingBytes });
|
|
388
|
+
sampleStarted = now; peakMessages = pendingMessages; peakBytes = pendingBytes;
|
|
389
|
+
maxWaitMs = 0; receivedMessages = 0;
|
|
390
|
+
}
|
|
326
391
|
ws.on('message', (bytes, binary) => {
|
|
392
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
393
|
+
const size = bytes.length;
|
|
394
|
+
if (pendingMessages >= maxPendingMessages || pendingBytes + size > maxPendingBytes) {
|
|
395
|
+
log({ event: 'inbound-queue-limit', role: role || 'unauthenticated',
|
|
396
|
+
pendingMessages, pendingBytes, incomingBytes: size, maxWaitMs });
|
|
397
|
+
ws.close(4432, 'Inbound queue limit exceeded');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
pendingMessages++; pendingBytes += size;
|
|
401
|
+
receivedMessages++;
|
|
402
|
+
peakMessages = Math.max(peakMessages, pendingMessages); peakBytes = Math.max(peakBytes, pendingBytes);
|
|
403
|
+
const enqueuedAt = Date.now();
|
|
327
404
|
messageTail = messageTail.then(async () => {
|
|
328
405
|
try {
|
|
406
|
+
maxWaitMs = Math.max(maxWaitMs, Date.now() - enqueuedAt);
|
|
407
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
329
408
|
const message = decode(bytes, binary);
|
|
330
409
|
if (!role) {
|
|
410
|
+
let userId;
|
|
411
|
+
if (!['host', 'file-host', 'observe'].includes(message.type) &&
|
|
412
|
+
!(['pair', 'resume'].includes(message.type) && message.management === true)) {
|
|
413
|
+
if (!['pair', 'resume', 'observe', 'file-client'].includes(message.type)) return fail();
|
|
414
|
+
if (!accounts || !validSecret(message.accountToken)) return fail(4431, 'Account login required');
|
|
415
|
+
try { userId = (await accounts.current(message.accountToken)).id; }
|
|
416
|
+
catch { return fail(4431, 'Account session invalid'); }
|
|
417
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
418
|
+
if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
|
|
419
|
+
}
|
|
331
420
|
if (message.type === 'file-host') {
|
|
332
421
|
room = rooms.get(message.deviceId);
|
|
333
422
|
if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
|
|
@@ -348,7 +437,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
348
437
|
return fail(4429, 'Too many file uploads');
|
|
349
438
|
}
|
|
350
439
|
role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
|
|
351
|
-
room.uploadClients.set(clientId, { ws, session });
|
|
440
|
+
room.uploadClients.set(clientId, { ws, session, userId });
|
|
352
441
|
send(room.uploadHost, { type: 'file-open', session }); return;
|
|
353
442
|
}
|
|
354
443
|
if (message.type === 'host') {
|
|
@@ -362,18 +451,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
362
451
|
}
|
|
363
452
|
catch { return fail(1008, 'Invalid LAN candidates'); }
|
|
364
453
|
room = durable ? rooms.get(message.deviceId) : undefined;
|
|
365
|
-
if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
366
454
|
if (!room) {
|
|
367
|
-
// Durable computers prove future ownership with their generated device secret.
|
|
368
|
-
// The legacy deployment key remains accepted, but is no longer required for first registration.
|
|
369
455
|
if (!durable && !matches(message.key, hostHash)) return fail();
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
|
|
375
|
-
await commit(room, {}); rooms.set(room.id, room);
|
|
376
|
-
}
|
|
456
|
+
room = await registerRoom(durable ? message.deviceId : randomUUID(), durable, message.deviceSecret);
|
|
457
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
458
|
+
}
|
|
459
|
+
if (durable && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
377
460
|
const oldHost = room.host;
|
|
378
461
|
room.observers ||= new Map();
|
|
379
462
|
if (oldHost && oldHost !== ws) {
|
|
@@ -417,6 +500,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
417
500
|
await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
|
|
418
501
|
room.pairingHash = undefined;
|
|
419
502
|
} finally { room.pairingInProgress = false; }
|
|
503
|
+
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
420
504
|
send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
|
|
421
505
|
} else {
|
|
422
506
|
clientId = message.clientId;
|
|
@@ -427,11 +511,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
427
511
|
return fail(message.type === 'pair' ? 4408 : 4401, message.type === 'pair' ? 'Pairing code expired or used' : 'Device authorization rejected');
|
|
428
512
|
}
|
|
429
513
|
}
|
|
514
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
515
|
+
if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
|
|
430
516
|
if (message.type === 'observe') {
|
|
431
517
|
role = 'observer'; clearTimeout(authTimer); room.observers ||= new Map();
|
|
432
518
|
const oldObserver = room.observers.get(clientId);
|
|
433
519
|
if (oldObserver?.ws && oldObserver.ws !== ws) oldObserver.ws.close(4409, 'Metadata connection replaced by same identity');
|
|
434
|
-
room.observers.set(clientId, { ws });
|
|
520
|
+
room.observers.set(clientId, { ws, userId });
|
|
435
521
|
send(ws, { type: 'observing', online: Boolean(room.host) });
|
|
436
522
|
send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
437
523
|
const announcement = adminState.read().announcement;
|
|
@@ -442,11 +528,6 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
442
528
|
return;
|
|
443
529
|
}
|
|
444
530
|
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
445
|
-
let userId;
|
|
446
|
-
if (accounts && message.accountToken !== undefined) {
|
|
447
|
-
try { userId = (await accounts.current(message.accountToken)).id; }
|
|
448
|
-
catch { return fail(4431, 'Account session invalid'); }
|
|
449
|
-
}
|
|
450
531
|
const oldConnection = room.connections.get(clientId);
|
|
451
532
|
role = 'client'; clearTimeout(authTimer); session = randomUUID();
|
|
452
533
|
if (oldConnection?.ws && oldConnection.ws !== ws) {
|
|
@@ -454,7 +535,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
454
535
|
send(room.host, { type: 'detach', session: oldConnection.session });
|
|
455
536
|
oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
|
|
456
537
|
}
|
|
457
|
-
room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
|
|
538
|
+
room.connections.set(clientId, { ws, session, p2pProbe: Boolean(userId) && room.p2pProbe && message.p2pProbe === 1,
|
|
458
539
|
userId,
|
|
459
540
|
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
460
541
|
relayAdmitted: false, relayExempt: false,
|
|
@@ -465,7 +546,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
465
546
|
const announcement = adminState.read().announcement;
|
|
466
547
|
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
467
548
|
send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
|
|
468
|
-
}
|
|
549
|
+
}
|
|
469
550
|
if (role === 'host') {
|
|
470
551
|
if (room.host !== ws) return;
|
|
471
552
|
if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
|
|
@@ -627,15 +708,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
627
708
|
releaseRelay(room.connections.get(clientId));
|
|
628
709
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|
|
629
710
|
const connection = room.connections.get(clientId);
|
|
630
|
-
if (!
|
|
711
|
+
if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
|
|
712
|
+
if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
|
|
631
713
|
ws.close(4428, 'Server busy'); return;
|
|
632
714
|
}
|
|
633
715
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
634
|
-
} else fail();
|
|
716
|
+
} else fail();
|
|
635
717
|
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
718
|
+
finally { pendingMessages--; pendingBytes -= size; }
|
|
636
719
|
}).catch(() => ws.close(1011, 'Relay operation failed'));
|
|
637
720
|
});
|
|
638
|
-
});
|
|
721
|
+
});
|
|
639
722
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
640
723
|
listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
|
|
641
724
|
return {
|
|
@@ -645,7 +728,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
645
728
|
await new Promise(resolve => wss.close(resolve));
|
|
646
729
|
await new Promise(resolve => server.close(resolve));
|
|
647
730
|
if (database) await database.close();
|
|
648
|
-
},
|
|
731
|
+
},
|
|
649
732
|
};
|
|
650
733
|
}
|
|
651
734
|
|
|
@@ -719,6 +802,20 @@ function plainText(value, limit, empty) {
|
|
|
719
802
|
return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
|
|
720
803
|
}
|
|
721
804
|
|
|
805
|
+
// Device-authorized management must not become an anonymous chat tunnel.
|
|
806
|
+
function managementPayload(value) {
|
|
807
|
+
try {
|
|
808
|
+
const message = JSON.parse(value);
|
|
809
|
+
if (['unsubscribe', 'cancel', 'response-ack'].includes(message.type)) return true;
|
|
810
|
+
if (message.type === 'subscribe') return message.path === '/api/shared-recovery/events';
|
|
811
|
+
if (message.type !== 'request') return false;
|
|
812
|
+
const method = message.method || 'GET';
|
|
813
|
+
if (method === 'GET') return ['/api/health', '/api/official-remote/status'].includes(message.path) ||
|
|
814
|
+
/^\/api\/threads\/[0-9a-f-]{28,64}\/model-changes(?:\?|$)/i.test(message.path);
|
|
815
|
+
return method === 'POST' && message.path === '/api/official-remote/pairing';
|
|
816
|
+
} catch { return false; }
|
|
817
|
+
}
|
|
818
|
+
|
|
722
819
|
function controlPayload(value) {
|
|
723
820
|
try {
|
|
724
821
|
const message = JSON.parse(value);
|