@walkhi/code-relax 0.1.0-beta.11 → 0.1.0-beta.12
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/codex-remote.mjs +13 -3
- package/dist/bin/migrate-relay-db.mjs +104 -0
- package/dist/bin/self-relay-server.mjs +17 -10
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/src/managed-app-server.mjs +13 -4
- package/dist/src/platform/windows/codex-runtime.mjs +138 -4
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
- package/dist/src/platform/windows/resident-start.js +8 -0
- package/dist/src/platform/windows/resident-startup.ps1 +6 -3
- package/dist/src/self-relay/admin-state.mjs +8 -6
- package/dist/src/self-relay/email-accounts.mjs +125 -0
- package/dist/src/self-relay/lifecycle.mjs +48 -80
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/server.mjs +88 -26
- package/dist/src/server.mjs +745 -726
- package/dist/src/shared-app-server.mjs +285 -285
- package/package.json +2 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
2
3
|
import { deviceStore, validId } from './device-store.mjs';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { WebSocketServer } from 'ws';
|
|
@@ -6,12 +7,14 @@ import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
|
6
7
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
7
8
|
import { validProbeSignal } from './p2p-probe.mjs';
|
|
8
9
|
import { relayAdminState } from './admin-state.mjs';
|
|
10
|
+
import { openRelayMysql } from './mysql-state.mjs';
|
|
9
11
|
import { adminPage } from './admin-page.mjs';
|
|
12
|
+
import { openEmailAccounts } from './email-accounts.mjs';
|
|
10
13
|
|
|
11
14
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
12
15
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
13
16
|
maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
14
|
-
maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
17
|
+
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
15
18
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
16
19
|
if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
|
|
17
20
|
if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
|
|
@@ -21,24 +24,34 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
21
24
|
const nativeOrigins = publicRelayUrl ? ['http:', 'https:'].map(protocol => `${protocol}//${new URL(publicRelayUrl).host}`) : [];
|
|
22
25
|
if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
|
|
23
26
|
const hostHash = digest(hostKey), rooms = new Map();
|
|
24
|
-
const
|
|
27
|
+
const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
|
|
28
|
+
if (accountConfigFile && !database?.schemaVersions.includes('002_email_accounts')) throw new Error('Email account schema is required');
|
|
29
|
+
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
30
|
+
const store = database?.devices || deviceStore(stateFile);
|
|
25
31
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
26
|
-
const adminState = relayAdminState(adminStateFile, maxRelaySessions);
|
|
32
|
+
const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
|
|
27
33
|
let activeRelaySessions = 0;
|
|
28
34
|
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
|
|
35
|
+
let commitTail = Promise.resolve();
|
|
29
36
|
function commit(room, changes) {
|
|
37
|
+
const operation = commitTail.then(async () => {
|
|
38
|
+
changes = typeof changes === 'function' ? changes(room) : changes;
|
|
30
39
|
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
31
40
|
if (room.durable) {
|
|
32
41
|
const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
|
|
33
|
-
store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
|
|
42
|
+
await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
|
|
34
43
|
}
|
|
35
|
-
Object.assign(room, changes, { updatedAt: next.updatedAt });
|
|
44
|
+
Object.assign(room, changes, { updatedAt: next.updatedAt });
|
|
45
|
+
});
|
|
46
|
+
commitTail = operation.catch(() => {});
|
|
47
|
+
return operation;
|
|
36
48
|
}
|
|
37
49
|
function invitation(room) {
|
|
38
50
|
const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
|
|
39
51
|
return { code, expires: room.expires };
|
|
40
52
|
}
|
|
41
53
|
let listenUrl = '';
|
|
54
|
+
let adminTail = Promise.resolve();
|
|
42
55
|
function relayUrl(request) {
|
|
43
56
|
if (publicRelayUrl) return relayPublicAddress(publicRelayUrl);
|
|
44
57
|
const host = request.headers.host || new URL(listenUrl).host;
|
|
@@ -52,7 +65,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
52
65
|
const server = http.createServer((request, response) => {
|
|
53
66
|
const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
54
67
|
if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
|
|
55
|
-
|
|
68
|
+
adminTail = adminTail.then(() => handleAdmin(request, response, target)).catch(() => {
|
|
69
|
+
if (!response.headersSent) adminReply(response, 500, { error: 'Admin operation failed' });
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (accounts && target.pathname.startsWith('/account/')) {
|
|
74
|
+
void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
|
|
75
|
+
return;
|
|
56
76
|
}
|
|
57
77
|
if (request.method === 'GET' && target.pathname === '/announcement') {
|
|
58
78
|
const body = JSON.stringify({ announcement: adminState.read().announcement });
|
|
@@ -83,6 +103,41 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
83
103
|
'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
|
|
84
104
|
response.end(value);
|
|
85
105
|
}
|
|
106
|
+
function accountReply(response, status, body) {
|
|
107
|
+
if (response.headersSent || response.destroyed) return;
|
|
108
|
+
const value = JSON.stringify(body);
|
|
109
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(value),
|
|
110
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
111
|
+
response.end(value);
|
|
112
|
+
}
|
|
113
|
+
async function handleAccount(request, response, target) {
|
|
114
|
+
try {
|
|
115
|
+
if (target.pathname === '/account/config' && request.method === 'GET') {
|
|
116
|
+
accountReply(response, 200, { captchaAppId: accounts.captchaAppId }); return;
|
|
117
|
+
}
|
|
118
|
+
const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
|
|
119
|
+
if (target.pathname === '/account/me' && request.method === 'GET') {
|
|
120
|
+
accountReply(response, 200, { user: await accounts.current(bearer) }); return;
|
|
121
|
+
}
|
|
122
|
+
if (target.pathname === '/account/logout' && request.method === 'POST') {
|
|
123
|
+
accountReply(response, 200, await accounts.logout(bearer)); return;
|
|
124
|
+
}
|
|
125
|
+
if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
|
|
126
|
+
accountReply(response, 404, { error: 'Account route not found' }); return;
|
|
127
|
+
}
|
|
128
|
+
const body = JSON.parse(await readBody(request, 4096));
|
|
129
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) { accountReply(response, 400, { error: 'Invalid request body' }); return; }
|
|
130
|
+
if (target.pathname === '/account/email/request') {
|
|
131
|
+
const ip = request.headers['x-real-ip'] || request.socket.remoteAddress || '';
|
|
132
|
+
if (typeof ip !== 'string' || !isIP(ip)) { accountReply(response, 400, { error: 'Invalid client address' }); return; }
|
|
133
|
+
accountReply(response, 202, await accounts.requestCode(body, ip));
|
|
134
|
+
} else if (target.pathname === '/account/email/verify') {
|
|
135
|
+
accountReply(response, 200, await accounts.verifyCode(body));
|
|
136
|
+
} else accountReply(response, 404, { error: 'Account route not found' });
|
|
137
|
+
} catch (failure) {
|
|
138
|
+
accountReply(response, failure.status || 503, { error: failure.status ? failure.message : 'Account service unavailable' });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
86
141
|
async function handleAdmin(request, response, target) {
|
|
87
142
|
if (request.method === 'GET' && target.pathname === '/admin') {
|
|
88
143
|
adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
|
|
@@ -101,10 +156,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
101
156
|
try {
|
|
102
157
|
const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
|
|
103
158
|
if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
|
|
104
|
-
const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
159
|
+
const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
105
160
|
log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
|
|
106
161
|
} else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
|
|
107
|
-
const state = adminState.createAnnouncement(body.title, body.message, body.publish === true);
|
|
162
|
+
const state = await adminState.createAnnouncement(body.title, body.message, body.publish === true);
|
|
108
163
|
if (body.publish === true) {
|
|
109
164
|
broadcastAnnouncement(state.announcement);
|
|
110
165
|
log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
|
|
@@ -114,17 +169,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
114
169
|
const id = parts[0];
|
|
115
170
|
if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
116
171
|
if (parts.length === 1 && request.method === 'PUT') {
|
|
117
|
-
adminState.updateDraft(id, body.title, body.message);
|
|
172
|
+
await adminState.updateDraft(id, body.title, body.message);
|
|
118
173
|
log({ event: 'admin-announcement-draft-updated', announcementId: id });
|
|
119
174
|
} else if (parts.length === 1 && request.method === 'DELETE') {
|
|
120
|
-
adminState.deleteAnnouncement(id);
|
|
175
|
+
await adminState.deleteAnnouncement(id);
|
|
121
176
|
log({ event: 'admin-announcement-deleted', announcementId: id });
|
|
122
177
|
} else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
|
|
123
|
-
const state = adminState.publishDraft(id, body.title, body.message);
|
|
178
|
+
const state = await adminState.publishDraft(id, body.title, body.message);
|
|
124
179
|
broadcastAnnouncement(state.announcement);
|
|
125
180
|
log({ event: 'admin-announcement-published', announcementId: id });
|
|
126
181
|
} else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
|
|
127
|
-
const state = adminState.withdraw(id);
|
|
182
|
+
const state = await adminState.withdraw(id);
|
|
128
183
|
broadcastAnnouncement(state.announcement);
|
|
129
184
|
log({ event: 'admin-announcement-withdrawn', announcementId: id });
|
|
130
185
|
} else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
@@ -214,8 +269,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
214
269
|
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
|
|
215
270
|
}
|
|
216
271
|
});
|
|
217
|
-
|
|
218
|
-
|
|
272
|
+
let messageTail = Promise.resolve();
|
|
273
|
+
ws.on('message', (bytes, binary) => {
|
|
274
|
+
messageTail = messageTail.then(async () => {
|
|
275
|
+
try {
|
|
219
276
|
const message = decode(bytes, binary);
|
|
220
277
|
if (!role) {
|
|
221
278
|
if (message.type === 'file-host') {
|
|
@@ -262,7 +319,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
262
319
|
room = { id: durable ? message.deviceId : randomUUID(), durable,
|
|
263
320
|
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
|
|
264
321
|
...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
|
|
265
|
-
commit(room, {}); rooms.set(room.id, room);
|
|
322
|
+
await commit(room, {}); rooms.set(room.id, room);
|
|
266
323
|
}
|
|
267
324
|
const oldHost = room.host;
|
|
268
325
|
room.observers ||= new Map();
|
|
@@ -286,7 +343,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
286
343
|
room = rooms.get(message.room);
|
|
287
344
|
if (!room || room.status !== 'active') return fail();
|
|
288
345
|
let credential;
|
|
289
|
-
if (message.type === 'pair' && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
|
|
346
|
+
if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
|
|
290
347
|
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
291
348
|
credential = message.credential || secret();
|
|
292
349
|
clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
|
|
@@ -295,8 +352,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
295
352
|
if (!existing && room.clients.length >= maxClientsPerHost) return fail(4429, 'Mobile device limit reached');
|
|
296
353
|
const now = new Date().toISOString();
|
|
297
354
|
const client = { id: clientId, hash: digest(credential), createdAt: existing?.createdAt || now, updatedAt: now };
|
|
298
|
-
|
|
299
|
-
|
|
355
|
+
room.pairingInProgress = true;
|
|
356
|
+
try {
|
|
357
|
+
await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
|
|
358
|
+
room.pairingHash = undefined;
|
|
359
|
+
} finally { room.pairingInProgress = false; }
|
|
300
360
|
send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
|
|
301
361
|
} else {
|
|
302
362
|
clientId = message.clientId;
|
|
@@ -355,7 +415,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
355
415
|
send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
|
|
356
416
|
}
|
|
357
417
|
if (message.type === 'revoke') {
|
|
358
|
-
commit(room, { clients: [], revision:
|
|
418
|
+
await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
|
|
359
419
|
room.pairingHash = undefined;
|
|
360
420
|
for (const connection of room.connections.values()) {
|
|
361
421
|
releaseRelay(connection);
|
|
@@ -398,7 +458,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
398
458
|
send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
|
|
399
459
|
} else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
|
|
400
460
|
const computer = normalizeOfficialComputer(message);
|
|
401
|
-
commit(room, { officialComputer: computer });
|
|
461
|
+
await commit(room, { officialComputer: computer });
|
|
402
462
|
send(ws, { type: 'official-computer-saved', computer });
|
|
403
463
|
} else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
|
|
404
464
|
const connection = room.connections.get(clientId);
|
|
@@ -428,17 +488,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
428
488
|
}
|
|
429
489
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
430
490
|
} else fail();
|
|
431
|
-
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
432
|
-
|
|
491
|
+
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
492
|
+
}).catch(() => ws.close(1011, 'Relay operation failed'));
|
|
493
|
+
});
|
|
433
494
|
});
|
|
434
495
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
435
496
|
listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
|
|
436
497
|
return {
|
|
437
498
|
url: listenUrl, hostKey,
|
|
438
499
|
async close() {
|
|
439
|
-
for (const ws of wss.clients) ws.terminate();
|
|
440
|
-
await new Promise(resolve => wss.close(resolve));
|
|
441
|
-
await new Promise(resolve => server.close(resolve));
|
|
500
|
+
for (const ws of wss.clients) ws.terminate();
|
|
501
|
+
await new Promise(resolve => wss.close(resolve));
|
|
502
|
+
await new Promise(resolve => server.close(resolve));
|
|
503
|
+
if (database) await database.close();
|
|
442
504
|
},
|
|
443
505
|
};
|
|
444
506
|
}
|