@walkhi/code-relax 0.1.0-beta.22 → 0.1.0-beta.24
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 +20 -19
- package/dist/shared/app-server-events.cjs +107 -100
- package/dist/shared/service-endpoints.cjs +21 -0
- package/dist/shared/timeline-sync.cjs +94 -5
- package/dist/skills/code-relax/SKILL.md +21 -18
- package/dist/src/lan-socket-server.mjs +50 -10
- package/dist/src/self-relay/account-cleanup.mjs +30 -0
- package/dist/src/self-relay/admin-page.mjs +8 -14
- 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/lifecycle.mjs +160 -160
- package/dist/src/self-relay/privacy-page.mjs +26 -0
- package/dist/src/self-relay/server.mjs +153 -67
- package/dist/src/self-relay/usage-meter.mjs +141 -136
- package/dist/src/server.mjs +102 -203
- package/dist/src/shared-app-server.mjs +132 -116
- package/dist/src/shared-thread-stream.mjs +56 -50
- package/dist/src/turn-model-history.mjs +47 -0
- package/package.json +1 -1
|
@@ -1,20 +1,20 @@
|
|
|
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
19
|
maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
20
20
|
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
|
|
@@ -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);
|
|
@@ -54,11 +58,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
54
58
|
});
|
|
55
59
|
commitTail = operation.catch(() => {});
|
|
56
60
|
return operation;
|
|
57
|
-
}
|
|
58
|
-
function invitation(room) {
|
|
59
|
-
const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
|
|
60
|
-
return { code, expires: room.expires };
|
|
61
|
-
}
|
|
61
|
+
}
|
|
62
|
+
function invitation(room) {
|
|
63
|
+
const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
|
|
64
|
+
return { code, expires: room.expires };
|
|
65
|
+
}
|
|
62
66
|
let listenUrl = '';
|
|
63
67
|
let adminTail = Promise.resolve();
|
|
64
68
|
function relayUrl(request) {
|
|
@@ -79,6 +83,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
79
83
|
});
|
|
80
84
|
return;
|
|
81
85
|
}
|
|
86
|
+
if (target.pathname === '/account/privacy' && request.method === 'GET') {
|
|
87
|
+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache',
|
|
88
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
89
|
+
'X-Content-Type-Options': 'nosniff' });
|
|
90
|
+
response.end(privacyPage); return;
|
|
91
|
+
}
|
|
82
92
|
if (accounts && target.pathname.startsWith('/account/')) {
|
|
83
93
|
void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
|
|
84
94
|
return;
|
|
@@ -132,6 +142,33 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
132
142
|
if (target.pathname === '/account/logout' && request.method === 'POST') {
|
|
133
143
|
accountReply(response, 200, await accounts.logout(bearer)); return;
|
|
134
144
|
}
|
|
145
|
+
if (target.pathname === '/account/me' && request.method === 'DELETE') {
|
|
146
|
+
const user = await accounts.current(bearer);
|
|
147
|
+
cancelledAccountIds.add(user.id);
|
|
148
|
+
try { await accounts.cancelAccount(bearer); }
|
|
149
|
+
catch (failure) { cancelledAccountIds.delete(user.id); throw failure; }
|
|
150
|
+
for (const room of rooms.values()) {
|
|
151
|
+
for (const [key, arm] of room.usageArms || []) if (arm.userId === user.id) room.usageArms.delete(key);
|
|
152
|
+
for (const [key, task] of room.usageTasks || []) if (task.userId === user.id) room.usageTasks.delete(key);
|
|
153
|
+
for (const [clientId, connection] of room.connections) {
|
|
154
|
+
if (connection.userId !== user.id) continue;
|
|
155
|
+
releaseRelay(connection); room.connections.delete(clientId);
|
|
156
|
+
if (room.host) send(room.host, { type: 'detach', session: connection.session });
|
|
157
|
+
connection.ws.close(4431, 'Account cancelled');
|
|
158
|
+
}
|
|
159
|
+
for (const [clientId, observer] of room.observers) {
|
|
160
|
+
if (observer.userId !== user.id) continue;
|
|
161
|
+
room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
|
|
162
|
+
}
|
|
163
|
+
for (const [clientId, upload] of room.uploadClients) {
|
|
164
|
+
if (upload.userId !== user.id) continue;
|
|
165
|
+
room.uploadClients.delete(clientId);
|
|
166
|
+
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
167
|
+
upload.ws.close(4431, 'Account cancelled');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
accountReply(response, 200, { ok: true }); return;
|
|
171
|
+
}
|
|
135
172
|
if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
|
|
136
173
|
accountReply(response, 404, { error: 'Account route not found' }); return;
|
|
137
174
|
}
|
|
@@ -149,26 +186,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
149
186
|
}
|
|
150
187
|
}
|
|
151
188
|
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
|
-
}
|
|
189
|
+
const accessKey = request.headers['x-code-relax-admin-key'];
|
|
190
|
+
const internalAccess = adminAccessKey && validSecret(accessKey) &&
|
|
191
|
+
timingSafeEqual(Buffer.from(accessKey), Buffer.from(adminAccessKey));
|
|
192
|
+
const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
|
|
193
|
+
const diagnosticRoute = ['/admin/api/usage-diagnostics', '/admin/api/usage-diagnostics/drain'].includes(target.pathname);
|
|
194
|
+
const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
|
|
195
|
+
timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
|
|
196
|
+
if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
|
|
172
197
|
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
173
198
|
const state = adminState.read();
|
|
174
199
|
adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
|
|
@@ -188,14 +213,18 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
188
213
|
}
|
|
189
214
|
adminReply(response, 200, await usageReport(database.pool, page, view)); return;
|
|
190
215
|
}
|
|
216
|
+
const diagnosticSource = target.searchParams.get('source') || 'usage';
|
|
217
|
+
if (target.pathname.startsWith('/admin/api/usage-diagnostics') && !['usage', 'account-cleanup'].includes(diagnosticSource)) {
|
|
218
|
+
adminReply(response, 400, { error: 'Invalid diagnostic source' }); return;
|
|
219
|
+
}
|
|
191
220
|
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(
|
|
221
|
+
if (request.method === 'GET') adminReply(response, 200, await diagnostics.read(diagnosticSource));
|
|
222
|
+
else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear(diagnosticSource));
|
|
194
223
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
195
224
|
return;
|
|
196
225
|
}
|
|
197
226
|
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
|
|
198
|
-
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(
|
|
227
|
+
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(diagnosticSource));
|
|
199
228
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
200
229
|
return;
|
|
201
230
|
}
|
|
@@ -262,23 +291,26 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
262
291
|
serverNoContextTakeover: true, clientNoContextTakeover: true,
|
|
263
292
|
threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
|
|
264
293
|
} });
|
|
265
|
-
let admissionStart = Date.now(), admissions = 0;
|
|
266
|
-
server.on('upgrade', (request, socket, head) => {
|
|
294
|
+
let admissionStart = Date.now(), admissions = 0;
|
|
295
|
+
server.on('upgrade', (request, socket, head) => {
|
|
267
296
|
if (request.url !== '/relay' || (request.headers.origin && request.headers.origin !== 'null' && !nativeOrigins.includes(request.headers.origin))) {
|
|
268
297
|
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 > 60) {
|
|
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
|
-
});
|
|
298
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
|
|
299
|
+
}
|
|
300
|
+
if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
|
|
301
|
+
if (wss.clients.size >= maxConnections || ++admissions > 60) {
|
|
302
|
+
socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
|
|
303
|
+
}
|
|
304
|
+
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
|
|
305
|
+
});
|
|
277
306
|
wss.on('connection', ws => {
|
|
278
307
|
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(() => {
|
|
308
|
+
const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
|
|
309
|
+
ws.on('pong', () => { alive = true; });
|
|
310
|
+
const heartbeat = setInterval(() => {
|
|
311
|
+
sampleInboundQueue();
|
|
312
|
+
if (!alive) ws.terminate(); else { alive = false; ws.ping(); }
|
|
313
|
+
}, 30000);
|
|
282
314
|
const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
|
|
283
315
|
const queueClientMessage = (connection, message, pause = 0) => {
|
|
284
316
|
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
@@ -292,10 +324,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
292
324
|
send(connection.ws, message);
|
|
293
325
|
}).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
|
|
294
326
|
};
|
|
295
|
-
ws.on('error', () => {}); // close owns routing cleanup.
|
|
327
|
+
ws.on('error', () => {}); // close owns routing cleanup.
|
|
296
328
|
ws.on('close', (code, reason) => {
|
|
297
|
-
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
298
|
-
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
329
|
+
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
330
|
+
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
299
331
|
if (role === 'host' && room?.host === ws) {
|
|
300
332
|
for (const task of room.usageTasks?.values() || []) {
|
|
301
333
|
void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
|
|
@@ -303,10 +335,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
303
335
|
room.usageTasks?.clear(); room.usageArms?.clear();
|
|
304
336
|
room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
|
|
305
337
|
room.uploadHost?.close(4410, 'Computer offline');
|
|
306
|
-
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
|
|
338
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline');
|
|
307
339
|
for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
|
|
308
340
|
if (!room.durable) rooms.delete(room.id);
|
|
309
|
-
for (const connection of room.connections.values()) connection.ws.close(
|
|
341
|
+
for (const connection of room.connections.values()) connection.ws.close(
|
|
342
|
+
code === 4432 ? 4432 : 4410,
|
|
343
|
+
code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline; authorization retained');
|
|
310
344
|
} else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
311
345
|
const connection = room.connections.get(clientId);
|
|
312
346
|
releaseRelay(connection);
|
|
@@ -316,18 +350,57 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
316
350
|
room.observers.delete(clientId);
|
|
317
351
|
} else if (role === 'file-host' && room?.uploadHost === ws) {
|
|
318
352
|
room.uploadHost = undefined;
|
|
319
|
-
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
|
|
353
|
+
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
354
|
} else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
|
|
321
355
|
room.uploadClients.delete(clientId);
|
|
322
356
|
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
|
|
323
357
|
}
|
|
324
|
-
});
|
|
358
|
+
});
|
|
325
359
|
let messageTail = Promise.resolve();
|
|
360
|
+
// Include the in-flight message: async database work must not permit an
|
|
361
|
+
// unbounded chain of closures retaining incoming payloads.
|
|
362
|
+
let pendingMessages = 0, pendingBytes = 0;
|
|
363
|
+
const maxPendingMessages = 128, maxPendingBytes = 16 * 1024 * 1024;
|
|
364
|
+
let sampleStarted = Date.now(), peakMessages = 0, peakBytes = 0, maxWaitMs = 0, receivedMessages = 0;
|
|
365
|
+
function sampleInboundQueue() {
|
|
366
|
+
const now = Date.now();
|
|
367
|
+
if (now - sampleStarted < 60000) return;
|
|
368
|
+
// Summarize only intervals with an actual backlog; never log payloads or credentials.
|
|
369
|
+
if (peakMessages > 1) log({ event: 'inbound-queue-sample', role: role || 'unauthenticated',
|
|
370
|
+
windowMs: now - sampleStarted, receivedMessages, peakMessages, peakBytes, maxWaitMs,
|
|
371
|
+
pendingMessages, pendingBytes });
|
|
372
|
+
sampleStarted = now; peakMessages = pendingMessages; peakBytes = pendingBytes;
|
|
373
|
+
maxWaitMs = 0; receivedMessages = 0;
|
|
374
|
+
}
|
|
326
375
|
ws.on('message', (bytes, binary) => {
|
|
376
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
377
|
+
const size = bytes.length;
|
|
378
|
+
if (pendingMessages >= maxPendingMessages || pendingBytes + size > maxPendingBytes) {
|
|
379
|
+
log({ event: 'inbound-queue-limit', role: role || 'unauthenticated',
|
|
380
|
+
pendingMessages, pendingBytes, incomingBytes: size, maxWaitMs });
|
|
381
|
+
ws.close(4432, 'Inbound queue limit exceeded');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
pendingMessages++; pendingBytes += size;
|
|
385
|
+
receivedMessages++;
|
|
386
|
+
peakMessages = Math.max(peakMessages, pendingMessages); peakBytes = Math.max(peakBytes, pendingBytes);
|
|
387
|
+
const enqueuedAt = Date.now();
|
|
327
388
|
messageTail = messageTail.then(async () => {
|
|
328
389
|
try {
|
|
390
|
+
maxWaitMs = Math.max(maxWaitMs, Date.now() - enqueuedAt);
|
|
391
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
329
392
|
const message = decode(bytes, binary);
|
|
330
393
|
if (!role) {
|
|
394
|
+
let userId;
|
|
395
|
+
if (!['host', 'file-host', 'observe'].includes(message.type) &&
|
|
396
|
+
!(['pair', 'resume'].includes(message.type) && message.management === true)) {
|
|
397
|
+
if (!['pair', 'resume', 'observe', 'file-client'].includes(message.type)) return fail();
|
|
398
|
+
if (!accounts || !validSecret(message.accountToken)) return fail(4431, 'Account login required');
|
|
399
|
+
try { userId = (await accounts.current(message.accountToken)).id; }
|
|
400
|
+
catch { return fail(4431, 'Account session invalid'); }
|
|
401
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
402
|
+
if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
|
|
403
|
+
}
|
|
331
404
|
if (message.type === 'file-host') {
|
|
332
405
|
room = rooms.get(message.deviceId);
|
|
333
406
|
if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
|
|
@@ -348,7 +421,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
348
421
|
return fail(4429, 'Too many file uploads');
|
|
349
422
|
}
|
|
350
423
|
role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
|
|
351
|
-
room.uploadClients.set(clientId, { ws, session });
|
|
424
|
+
room.uploadClients.set(clientId, { ws, session, userId });
|
|
352
425
|
send(room.uploadHost, { type: 'file-open', session }); return;
|
|
353
426
|
}
|
|
354
427
|
if (message.type === 'host') {
|
|
@@ -368,12 +441,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
368
441
|
// The legacy deployment key remains accepted, but is no longer required for first registration.
|
|
369
442
|
if (!durable && !matches(message.key, hostHash)) return fail();
|
|
370
443
|
if (rooms.size >= 256) return fail(4429, 'Device limit reached');
|
|
371
|
-
const now = new Date().toISOString();
|
|
444
|
+
const now = new Date().toISOString();
|
|
372
445
|
room = { id: durable ? message.deviceId : randomUUID(), durable,
|
|
373
446
|
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
|
|
374
447
|
...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
|
|
375
448
|
await commit(room, {}); rooms.set(room.id, room);
|
|
376
|
-
}
|
|
449
|
+
}
|
|
377
450
|
const oldHost = room.host;
|
|
378
451
|
room.observers ||= new Map();
|
|
379
452
|
if (oldHost && oldHost !== ws) {
|
|
@@ -427,11 +500,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
427
500
|
return fail(message.type === 'pair' ? 4408 : 4401, message.type === 'pair' ? 'Pairing code expired or used' : 'Device authorization rejected');
|
|
428
501
|
}
|
|
429
502
|
}
|
|
503
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
504
|
+
if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
|
|
430
505
|
if (message.type === 'observe') {
|
|
431
506
|
role = 'observer'; clearTimeout(authTimer); room.observers ||= new Map();
|
|
432
507
|
const oldObserver = room.observers.get(clientId);
|
|
433
508
|
if (oldObserver?.ws && oldObserver.ws !== ws) oldObserver.ws.close(4409, 'Metadata connection replaced by same identity');
|
|
434
|
-
room.observers.set(clientId, { ws });
|
|
509
|
+
room.observers.set(clientId, { ws, userId });
|
|
435
510
|
send(ws, { type: 'observing', online: Boolean(room.host) });
|
|
436
511
|
send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
437
512
|
const announcement = adminState.read().announcement;
|
|
@@ -442,11 +517,6 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
442
517
|
return;
|
|
443
518
|
}
|
|
444
519
|
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
520
|
const oldConnection = room.connections.get(clientId);
|
|
451
521
|
role = 'client'; clearTimeout(authTimer); session = randomUUID();
|
|
452
522
|
if (oldConnection?.ws && oldConnection.ws !== ws) {
|
|
@@ -454,7 +524,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
454
524
|
send(room.host, { type: 'detach', session: oldConnection.session });
|
|
455
525
|
oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
|
|
456
526
|
}
|
|
457
|
-
room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
|
|
527
|
+
room.connections.set(clientId, { ws, session, p2pProbe: Boolean(userId) && room.p2pProbe && message.p2pProbe === 1,
|
|
458
528
|
userId,
|
|
459
529
|
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
460
530
|
relayAdmitted: false, relayExempt: false,
|
|
@@ -465,7 +535,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
465
535
|
const announcement = adminState.read().announcement;
|
|
466
536
|
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
467
537
|
send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
|
|
468
|
-
}
|
|
538
|
+
}
|
|
469
539
|
if (role === 'host') {
|
|
470
540
|
if (room.host !== ws) return;
|
|
471
541
|
if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
|
|
@@ -627,15 +697,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
627
697
|
releaseRelay(room.connections.get(clientId));
|
|
628
698
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|
|
629
699
|
const connection = room.connections.get(clientId);
|
|
630
|
-
if (!
|
|
700
|
+
if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
|
|
701
|
+
if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
|
|
631
702
|
ws.close(4428, 'Server busy'); return;
|
|
632
703
|
}
|
|
633
704
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
634
|
-
} else fail();
|
|
705
|
+
} else fail();
|
|
635
706
|
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
707
|
+
finally { pendingMessages--; pendingBytes -= size; }
|
|
636
708
|
}).catch(() => ws.close(1011, 'Relay operation failed'));
|
|
637
709
|
});
|
|
638
|
-
});
|
|
710
|
+
});
|
|
639
711
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
640
712
|
listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
|
|
641
713
|
return {
|
|
@@ -645,7 +717,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
645
717
|
await new Promise(resolve => wss.close(resolve));
|
|
646
718
|
await new Promise(resolve => server.close(resolve));
|
|
647
719
|
if (database) await database.close();
|
|
648
|
-
},
|
|
720
|
+
},
|
|
649
721
|
};
|
|
650
722
|
}
|
|
651
723
|
|
|
@@ -719,6 +791,20 @@ function plainText(value, limit, empty) {
|
|
|
719
791
|
return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
|
|
720
792
|
}
|
|
721
793
|
|
|
794
|
+
// Device-authorized management must not become an anonymous chat tunnel.
|
|
795
|
+
function managementPayload(value) {
|
|
796
|
+
try {
|
|
797
|
+
const message = JSON.parse(value);
|
|
798
|
+
if (['unsubscribe', 'cancel', 'response-ack'].includes(message.type)) return true;
|
|
799
|
+
if (message.type === 'subscribe') return message.path === '/api/shared-recovery/events';
|
|
800
|
+
if (message.type !== 'request') return false;
|
|
801
|
+
const method = message.method || 'GET';
|
|
802
|
+
if (method === 'GET') return ['/api/health', '/api/official-remote/status'].includes(message.path) ||
|
|
803
|
+
/^\/api\/threads\/[0-9a-f-]{28,64}\/model-changes(?:\?|$)/i.test(message.path);
|
|
804
|
+
return method === 'POST' && message.path === '/api/official-remote/pairing';
|
|
805
|
+
} catch { return false; }
|
|
806
|
+
}
|
|
807
|
+
|
|
722
808
|
function controlPayload(value) {
|
|
723
809
|
try {
|
|
724
810
|
const message = JSON.parse(value);
|