@walkhi/code-relax 0.1.0-beta.18 → 0.1.0-beta.19
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/migrate-relay-db.mjs +2 -1
- package/dist/bin/self-relay-server.mjs +1 -0
- package/dist/src/self-relay/admin-server.mjs +2 -2
- package/dist/src/self-relay/connector.mjs +3 -2
- package/dist/src/self-relay/mysql-state.mjs +1 -1
- package/dist/src/self-relay/server.mjs +52 -11
- package/dist/src/self-relay/usage-diagnostics.mjs +46 -0
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@ if (!['--schema', '--import', '--check'].includes(command) || !process.env.RELAY
|
|
|
13
13
|
const config = JSON.parse(fs.readFileSync(process.env.RELAY_MYSQL_CONFIG_FILE, 'utf8'));
|
|
14
14
|
const db = await mysql.createConnection({ ...config, dateStrings: true, timezone: 'Z', multipleStatements: false });
|
|
15
15
|
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
16
|
-
const migrations = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions'];
|
|
16
|
+
const migrations = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
|
|
17
17
|
try {
|
|
18
18
|
if (command === '--schema') {
|
|
19
19
|
const [existing] = await db.query("SHOW TABLES LIKE 'schema_migrations'");
|
|
@@ -42,6 +42,7 @@ try {
|
|
|
42
42
|
if (versions.some(row => row.version === '002_email_accounts')) names.push('email_login_challenges', 'user_sessions', 'email_send_limits');
|
|
43
43
|
if (versions.some(row => row.version === '003_relay_usage')) names.push('relay_admin_emails', 'relay_usage_settings', 'relay_usage_totals', 'relay_usage_computer_totals', 'relay_usage_entitlements', 'relay_usage_budgets', 'relay_usage_computer_budgets', 'relay_usage_sessions');
|
|
44
44
|
if (versions.some(row => row.version === '004_relay_usage_actions')) names.push('relay_usage_actions');
|
|
45
|
+
if (versions.some(row => row.version === '005_relay_usage_diagnostics')) names.push('relay_usage_diagnostics');
|
|
45
46
|
const counts = {};
|
|
46
47
|
for (const name of names) { const [rows] = await db.query(`SELECT COUNT(*) AS count FROM ${name}`); counts[name] = rows[0].count; }
|
|
47
48
|
if (process.env.RELAY_STATE_FILE && process.env.RELAY_ADMIN_STATE_FILE) {
|
|
@@ -52,6 +52,7 @@ if (revokeIndex >= 0) {
|
|
|
52
52
|
console.log('Computer and phone authorization revoked'); process.exit(0);
|
|
53
53
|
}
|
|
54
54
|
const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
|
|
55
|
+
usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
|
|
55
56
|
publicRelayUrl: process.env.RELAY_PUBLIC_URL || '', appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || '',
|
|
56
57
|
log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
|
|
57
58
|
console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
|
|
@@ -25,7 +25,7 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
25
25
|
if (request.method === 'GET' && target.pathname === '/admin/usage') {
|
|
26
26
|
reply(response, 200, adminUsagePage(), 'text/html; charset=utf-8'); return;
|
|
27
27
|
}
|
|
28
|
-
if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage'].includes(target.pathname)) {
|
|
28
|
+
if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage', '/admin/api/usage-diagnostics'].includes(target.pathname)) {
|
|
29
29
|
try {
|
|
30
30
|
const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
|
|
31
31
|
signal: AbortSignal.timeout(3000), headers: { Authorization: request.headers.authorization || '' } });
|
|
@@ -38,7 +38,7 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
38
38
|
}
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
|
-
if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE'].includes(request.method || '')) {
|
|
41
|
+
if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE', 'POST'].includes(request.method || '')) {
|
|
42
42
|
reply(response, 404, { error: 'Not found' }); return;
|
|
43
43
|
}
|
|
44
44
|
try {
|
|
@@ -228,8 +228,9 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
228
228
|
const request = decode(payload, false);
|
|
229
229
|
if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
230
230
|
if (request.type === 'request' && request.method === 'POST' && Number.isSafeInteger(request.id)) {
|
|
231
|
-
const
|
|
232
|
-
const
|
|
231
|
+
const path = (request.path || '').split('?')[0];
|
|
232
|
+
const existing = /^\/api\/threads\/([^/?]+)\/(?:messages|queue)$/.exec(path);
|
|
233
|
+
const created = /^\/api\/projects\/[^/?]+\/threads$/.test(path);
|
|
233
234
|
if (existing || created) {
|
|
234
235
|
const threadId = existing ? decodeURIComponent(existing[1]) : null;
|
|
235
236
|
const messageKey = typeof request.body?.clientUserMessageId === 'string' && request.body.clientUserMessageId.length <= 64
|
|
@@ -17,7 +17,7 @@ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
|
|
|
17
17
|
namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
|
|
18
18
|
try {
|
|
19
19
|
const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
|
|
20
|
-
const knownVersions = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions'];
|
|
20
|
+
const knownVersions = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
|
|
21
21
|
if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
|
|
22
22
|
for (const row of versions) {
|
|
23
23
|
if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import { isIP } from 'node:net';
|
|
3
4
|
import { deviceStore, validId } from './device-store.mjs';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
5
6
|
import { WebSocketServer } from 'ws';
|
|
6
7
|
import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
7
8
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
@@ -11,11 +12,13 @@ import { openRelayMysql } from './mysql-state.mjs';
|
|
|
11
12
|
import { adminPage } from './admin-page.mjs';
|
|
12
13
|
import { openEmailAccounts } from './email-accounts.mjs';
|
|
13
14
|
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
15
|
+
import { usageDiagnostics } from './usage-diagnostics.mjs';
|
|
14
16
|
|
|
15
17
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
16
18
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
17
19
|
maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
18
|
-
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile,
|
|
20
|
+
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
|
|
21
|
+
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
19
22
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
20
23
|
if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
|
|
21
24
|
if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
|
|
@@ -29,6 +32,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
29
32
|
if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
|
|
30
33
|
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
31
34
|
const meter = accounts ? usageMeter(database.pool) : null;
|
|
35
|
+
const diagnostics = database?.schemaVersions.includes('005_relay_usage_diagnostics')
|
|
36
|
+
? usageDiagnostics(database.pool, error => log({ event: 'usage-diagnostic-error', message: error.message })) : null;
|
|
37
|
+
const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
|
|
38
|
+
if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
|
|
32
39
|
const store = database?.devices || deviceStore(stateFile);
|
|
33
40
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
34
41
|
const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
|
|
@@ -147,11 +154,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
147
154
|
}
|
|
148
155
|
if (target.pathname.startsWith('/admin/api/')) {
|
|
149
156
|
const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
|
|
157
|
+
const diagnosticRoute = target.pathname === '/admin/api/usage-diagnostics' ||
|
|
158
|
+
target.pathname === '/admin/api/usage-diagnostics/drain';
|
|
159
|
+
const keyAccepted = diagnosticRoute && diagnosticKey && token &&
|
|
160
|
+
timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
|
|
150
161
|
let email;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
|
155
168
|
if (target.pathname === '/admin/api/session' && request.method === 'GET') {
|
|
156
169
|
adminReply(response, 200, { email }); return;
|
|
157
170
|
}
|
|
@@ -173,6 +186,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
173
186
|
}
|
|
174
187
|
adminReply(response, 200, await usageReport(database.pool, page, user)); return;
|
|
175
188
|
}
|
|
189
|
+
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
|
|
190
|
+
if (request.method === 'GET') adminReply(response, 200, await diagnostics.read());
|
|
191
|
+
else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear());
|
|
192
|
+
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
|
|
196
|
+
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain());
|
|
197
|
+
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
176
200
|
if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
|
|
177
201
|
request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
|
|
178
202
|
try {
|
|
@@ -433,6 +457,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
433
457
|
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
434
458
|
relayAdmitted: false, relayExempt: false,
|
|
435
459
|
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
460
|
+
diagnostics?.record('client', userId ? 'account-linked' : 'anonymous');
|
|
436
461
|
send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
|
|
437
462
|
send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
438
463
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
@@ -453,27 +478,41 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
453
478
|
const armKey = `${message.session}:${message.requestId}`;
|
|
454
479
|
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
455
480
|
if (message.type === 'usage-arm') {
|
|
481
|
+
diagnostics?.record('arm', connection?.userId ? 'account-linked' : 'anonymous');
|
|
456
482
|
if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
|
|
457
|
-
(message.threadId !== null && (!plainText(message.threadId, 128, false))))
|
|
483
|
+
(message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
|
|
484
|
+
diagnostics?.record('arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
458
487
|
for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
|
|
459
488
|
if (room.usageArms.size >= 256) return;
|
|
460
489
|
room.usageArms.set(armKey, { userId: connection.userId,
|
|
461
490
|
clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
|
|
462
491
|
session: message.session, at: Date.now() });
|
|
492
|
+
diagnostics?.record('arm-accepted', 'ready');
|
|
463
493
|
return;
|
|
464
494
|
}
|
|
465
|
-
if (message.type === 'usage-cancel') {
|
|
495
|
+
if (message.type === 'usage-cancel') {
|
|
496
|
+
diagnostics?.record('message-cancelled', room.usageArms.has(armKey) ? 'armed' : 'arm-missing');
|
|
497
|
+
room.usageArms.delete(armKey); return;
|
|
498
|
+
}
|
|
466
499
|
if (message.type === 'usage-message') {
|
|
467
500
|
const arm = room.usageArms.get(armKey);
|
|
501
|
+
diagnostics?.record('message', arm ? 'armed' : 'arm-missing');
|
|
468
502
|
room.usageArms.delete(armKey);
|
|
469
|
-
if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false))
|
|
503
|
+
if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
|
|
504
|
+
diagnostics?.record('message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
470
507
|
try {
|
|
471
508
|
await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
|
|
509
|
+
diagnostics?.record('message-counted', 'confirmed');
|
|
472
510
|
if (message.turnKind === 'start' && plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
|
|
473
511
|
const key = `${message.threadId}:${message.turnId}`;
|
|
474
512
|
if (key.length <= 128 && !room.usageTasks.has(key)) {
|
|
475
513
|
const task = { userId: arm.userId, clientId: arm.clientId };
|
|
476
514
|
await meter.action(task, room.id, arm.clientId, 'turn', key);
|
|
515
|
+
diagnostics?.record('turn-counted', 'confirmed');
|
|
477
516
|
if (message.observerReady === true) {
|
|
478
517
|
await meter.pulse(task, true, room.id, arm.clientId, key);
|
|
479
518
|
if (message.completed) await meter.pulse(task, false, room.id, arm.clientId);
|
|
@@ -481,7 +520,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
481
520
|
}
|
|
482
521
|
}
|
|
483
522
|
}
|
|
484
|
-
} catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
523
|
+
} catch (error) { diagnostics?.record('message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
|
|
485
524
|
return;
|
|
486
525
|
}
|
|
487
526
|
if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
|
|
@@ -492,12 +531,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
492
531
|
try {
|
|
493
532
|
if (message.method === 'resume' && !task) {
|
|
494
533
|
task = await meter.turnOrigin(room.id, key);
|
|
495
|
-
if (!task) return;
|
|
534
|
+
if (!task) { diagnostics?.record('turn-resume', 'origin-missing'); return; }
|
|
496
535
|
await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
|
|
536
|
+
diagnostics?.record('turn-resume', 'confirmed');
|
|
497
537
|
room.usageTasks.set(key, task);
|
|
498
538
|
} else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
|
|
499
539
|
else if (task && message.method === 'turn/completed') {
|
|
500
540
|
await meter.pulse(task, false, room.id, task.clientId);
|
|
541
|
+
diagnostics?.record('turn-completed', 'confirmed');
|
|
501
542
|
room.usageTasks.delete(key);
|
|
502
543
|
}
|
|
503
544
|
} catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const MAX_BATCH = 200;
|
|
2
|
+
|
|
3
|
+
export function usageDiagnostics(pool, onError = () => {}) {
|
|
4
|
+
let tail = Promise.resolve();
|
|
5
|
+
function serialize(action) {
|
|
6
|
+
const result = tail.then(action);
|
|
7
|
+
tail = result.catch(error => { onError(error); });
|
|
8
|
+
return result;
|
|
9
|
+
}
|
|
10
|
+
function record(event, detail) {
|
|
11
|
+
if (!/^[a-z-]{1,32}$/.test(event) || !/^[a-z-]{1,64}$/.test(detail)) throw new Error('Invalid usage diagnostic');
|
|
12
|
+
void serialize(async () => {
|
|
13
|
+
await pool.execute('INSERT INTO relay_usage_diagnostics (created_at,event,detail) VALUES (UTC_TIMESTAMP(3),?,?)', [event, detail]);
|
|
14
|
+
await pool.execute('DELETE FROM relay_usage_diagnostics WHERE created_at < UTC_TIMESTAMP(3) - INTERVAL 1 DAY');
|
|
15
|
+
}).catch(() => {});
|
|
16
|
+
}
|
|
17
|
+
async function read() {
|
|
18
|
+
return serialize(async () => {
|
|
19
|
+
const [rows] = await pool.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
|
|
20
|
+
ORDER BY id LIMIT ${MAX_BATCH + 1}`);
|
|
21
|
+
return { rows: rows.slice(0, MAX_BATCH), hasMore: rows.length > MAX_BATCH };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function clear() {
|
|
25
|
+
return serialize(async () => {
|
|
26
|
+
const [result] = await pool.query('DELETE FROM relay_usage_diagnostics');
|
|
27
|
+
return { cleared: result.affectedRows };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function drain() {
|
|
31
|
+
return serialize(async () => {
|
|
32
|
+
const db = await pool.getConnection();
|
|
33
|
+
try {
|
|
34
|
+
await db.beginTransaction();
|
|
35
|
+
const [rows] = await db.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
|
|
36
|
+
ORDER BY id LIMIT ${MAX_BATCH + 1} FOR UPDATE`);
|
|
37
|
+
const selected = rows.slice(0, MAX_BATCH);
|
|
38
|
+
if (selected.length) await db.execute('DELETE FROM relay_usage_diagnostics WHERE id <= ?', [selected.at(-1).id]);
|
|
39
|
+
await db.commit();
|
|
40
|
+
return { rows: selected, hasMore: rows.length > MAX_BATCH };
|
|
41
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
42
|
+
finally { db.release(); }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return { record, read, clear, drain };
|
|
46
|
+
}
|