@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.11
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/README.md +53 -64
- package/dist/bin/codex-remote.mjs +90 -71
- package/dist/bin/self-relay-admin.mjs +8 -0
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/bin/self-relay-server.mjs +5 -2
- package/dist/shared/app-server-events.cjs +64 -24
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +16 -21
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +59 -10
- package/dist/src/managed-app-server.mjs +36 -14
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +6 -8
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
- package/dist/src/platform/windows/background-process.mjs +7 -2
- package/dist/src/platform/windows/codex-runtime.mjs +117 -0
- package/dist/src/platform/windows/desktop-host.mjs +17 -3
- package/dist/src/platform/windows/desktop-info.ps1 +2 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
- package/dist/src/platform/windows/launch-worker.mjs +42 -0
- package/dist/src/platform/windows/resident-startup.ps1 +58 -18
- package/dist/src/platform/windows/service-host.mjs +4 -16
- package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
- package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
- package/dist/src/runtime-discovery.mjs +43 -0
- package/dist/src/self-relay/admin-cloud.mjs +101 -0
- package/dist/src/self-relay/admin-page.mjs +71 -0
- package/dist/src/self-relay/admin-server.mjs +61 -0
- package/dist/src/self-relay/admin-state.mjs +122 -0
- package/dist/src/self-relay/client.mjs +2 -0
- package/dist/src/self-relay/connector.mjs +107 -20
- package/dist/src/self-relay/demo.mjs +4 -5
- package/dist/src/self-relay/lifecycle.mjs +58 -70
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +224 -29
- package/dist/src/server.mjs +616 -1299
- package/dist/src/service-doctor.mjs +14 -26
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/service-lifecycle.mjs +12 -12
- package/dist/src/shared-app-server.mjs +206 -46
- package/dist/src/shared-catalog.mjs +5 -25
- package/dist/src/shared-recovery.mjs +11 -9
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/src/skill-install.mjs +0 -13
- package/package.json +16 -18
- package/tools/postinstall.mjs +140 -3
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -355
- package/dist/src/debug-ui.mjs +0 -131
- package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
- package/dist/src/platform/windows/desktop-tools.mjs +0 -48
- package/dist/src/platform/windows/launch-worker.ps1 +0 -13
- package/dist/src/thread-catalog.mjs +0 -42
- package/dist/tools/find-desktop-pipe.mjs +0 -10
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -57
- package/dist/web/chat-transport.js +0 -196
- package/dist/web/chat.css +0 -736
- package/dist/web/chat.js +0 -5149
- package/dist/web/client-platform.js +0 -11
- package/dist/web/harmony-platform.js +0 -51
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -146
- package/dist/web/index.html +0 -222
- package/dist/web/lan-session.js +0 -122
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -83
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -119
- package/dist/web/server-requests.js +0 -67
- package/dist/web/timeline-sync.js +0 -67
- package/dist/web/vendor/README.md +0 -14
- package/dist/web/vendor/highlight-powershell.min.js +0 -39
- package/dist/web/vendor/highlight.LICENSE.txt +0 -29
- package/dist/web/vendor/highlight.min.js +0 -1262
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import { deviceStore, validId } from './device-store.mjs';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { WebSocketServer } from 'ws';
|
|
5
5
|
import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
6
6
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
7
7
|
import { validProbeSignal } from './p2p-probe.mjs';
|
|
8
|
-
|
|
8
|
+
import { relayAdminState } from './admin-state.mjs';
|
|
9
|
+
import { adminPage } from './admin-page.mjs';
|
|
10
|
+
|
|
9
11
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
10
12
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
11
13
|
maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
12
|
-
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
14
|
+
maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
13
15
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
14
16
|
if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
|
|
17
|
+
if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
|
|
15
18
|
if (publicRelayUrl) relayPublicAddress(publicRelayUrl);
|
|
16
19
|
// Harmony's native WebSocket supplies the endpoint Origin automatically.
|
|
17
20
|
// libwebsockets can use http even for WSS; trust only the configured endpoint.
|
|
@@ -20,7 +23,9 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
20
23
|
const hostHash = digest(hostKey), rooms = new Map();
|
|
21
24
|
const store = deviceStore(stateFile);
|
|
22
25
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
23
|
-
|
|
26
|
+
const adminState = relayAdminState(adminStateFile, maxRelaySessions);
|
|
27
|
+
let activeRelaySessions = 0;
|
|
28
|
+
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
|
|
24
29
|
function commit(room, changes) {
|
|
25
30
|
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
26
31
|
if (room.durable) {
|
|
@@ -46,6 +51,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
46
51
|
}
|
|
47
52
|
const server = http.createServer((request, response) => {
|
|
48
53
|
const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
54
|
+
if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
|
|
55
|
+
void handleAdmin(request, response, target); return;
|
|
56
|
+
}
|
|
57
|
+
if (request.method === 'GET' && target.pathname === '/announcement') {
|
|
58
|
+
const body = JSON.stringify({ announcement: adminState.read().announcement });
|
|
59
|
+
response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
|
|
60
|
+
}
|
|
49
61
|
if (request.method !== 'GET' || !['/pair', '/pair/resolve'].includes(target.pathname)) {
|
|
50
62
|
response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
|
|
51
63
|
}
|
|
@@ -57,7 +69,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
57
69
|
response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
|
|
58
70
|
}
|
|
59
71
|
if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
|
|
60
|
-
const body = JSON.stringify({ version:
|
|
72
|
+
const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
|
|
61
73
|
response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
|
|
62
74
|
}
|
|
63
75
|
const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
|
|
@@ -65,6 +77,84 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
65
77
|
const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
|
|
66
78
|
response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
|
|
67
79
|
});
|
|
80
|
+
function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
81
|
+
const value = typeof body === 'string' ? body : JSON.stringify(body);
|
|
82
|
+
response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store',
|
|
83
|
+
'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
|
+
response.end(value);
|
|
85
|
+
}
|
|
86
|
+
async function handleAdmin(request, response, target) {
|
|
87
|
+
if (request.method === 'GET' && target.pathname === '/admin') {
|
|
88
|
+
adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
|
|
89
|
+
}
|
|
90
|
+
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
91
|
+
const state = adminState.read();
|
|
92
|
+
adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
|
|
93
|
+
activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
|
|
94
|
+
websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
|
|
95
|
+
activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
|
|
96
|
+
processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
|
|
97
|
+
announcement: state.announcement, announcements: state.announcements }); return;
|
|
98
|
+
}
|
|
99
|
+
if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
|
|
100
|
+
request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
|
|
101
|
+
try {
|
|
102
|
+
const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
|
|
103
|
+
if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
|
|
104
|
+
const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
105
|
+
log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
|
|
106
|
+
} else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
|
|
107
|
+
const state = adminState.createAnnouncement(body.title, body.message, body.publish === true);
|
|
108
|
+
if (body.publish === true) {
|
|
109
|
+
broadcastAnnouncement(state.announcement);
|
|
110
|
+
log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
|
|
111
|
+
} else log({ event: 'admin-announcement-draft-created', announcementId: state.announcements[0].id });
|
|
112
|
+
} else if (target.pathname.startsWith('/admin/api/announcements/')) {
|
|
113
|
+
const parts = target.pathname.slice('/admin/api/announcements/'.length).split('/');
|
|
114
|
+
const id = parts[0];
|
|
115
|
+
if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
116
|
+
if (parts.length === 1 && request.method === 'PUT') {
|
|
117
|
+
adminState.updateDraft(id, body.title, body.message);
|
|
118
|
+
log({ event: 'admin-announcement-draft-updated', announcementId: id });
|
|
119
|
+
} else if (parts.length === 1 && request.method === 'DELETE') {
|
|
120
|
+
adminState.deleteAnnouncement(id);
|
|
121
|
+
log({ event: 'admin-announcement-deleted', announcementId: id });
|
|
122
|
+
} else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
|
|
123
|
+
const state = adminState.publishDraft(id, body.title, body.message);
|
|
124
|
+
broadcastAnnouncement(state.announcement);
|
|
125
|
+
log({ event: 'admin-announcement-published', announcementId: id });
|
|
126
|
+
} else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
|
|
127
|
+
const state = adminState.withdraw(id);
|
|
128
|
+
broadcastAnnouncement(state.announcement);
|
|
129
|
+
log({ event: 'admin-announcement-withdrawn', announcementId: id });
|
|
130
|
+
} else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
131
|
+
} else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
132
|
+
adminReply(response, 200, { ok: true });
|
|
133
|
+
} catch (error) { adminReply(response, 400, { error: error.message }); }
|
|
134
|
+
}
|
|
135
|
+
function broadcastAnnouncement(announcement) {
|
|
136
|
+
for (const room of rooms.values()) {
|
|
137
|
+
for (const observer of room.observers.values()) send(observer.ws, { type: 'announcement', announcement });
|
|
138
|
+
for (const connection of room.connections.values()) send(connection.ws, { type: 'announcement', announcement });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function releaseRelay(connection) {
|
|
142
|
+
if (!connection?.relayAdmitted) return;
|
|
143
|
+
connection.relayAdmitted = false; activeRelaySessions = Math.max(0, activeRelaySessions - 1);
|
|
144
|
+
}
|
|
145
|
+
function admitRelay(connection, room) {
|
|
146
|
+
if (connection.relayAdmitted || connection.relayExempt) return true;
|
|
147
|
+
if (connection.capacityExempt) {
|
|
148
|
+
connection.relayExempt = true;
|
|
149
|
+
log({ event: 'relay-capacity-exempt', device: room.id }); return true;
|
|
150
|
+
}
|
|
151
|
+
const limit = adminState.read().maxRelaySessions;
|
|
152
|
+
if (activeRelaySessions >= limit) {
|
|
153
|
+
log({ event: 'relay-capacity-rejected', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return false;
|
|
154
|
+
}
|
|
155
|
+
connection.relayAdmitted = true; activeRelaySessions++;
|
|
156
|
+
log({ event: 'relay-capacity-admitted', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return true;
|
|
157
|
+
}
|
|
68
158
|
const wss = new WebSocketServer({ noServer: true, maxPayload, perMessageDeflate: {
|
|
69
159
|
serverNoContextTakeover: true, clientNoContextTakeover: true,
|
|
70
160
|
threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
|
|
@@ -81,32 +171,76 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
81
171
|
}
|
|
82
172
|
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
|
|
83
173
|
});
|
|
84
|
-
wss.on('connection', ws => {
|
|
174
|
+
wss.on('connection', ws => {
|
|
85
175
|
let role, room, session, clientId, alive = true;
|
|
86
176
|
const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
|
|
87
177
|
ws.on('pong', () => { alive = true; });
|
|
88
178
|
const heartbeat = setInterval(() => { if (!alive) ws.terminate(); else { alive = false; ws.ping(); } }, 30000);
|
|
89
|
-
const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
|
|
179
|
+
const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
|
|
180
|
+
const queueClientMessage = (connection, message, pause = 0) => {
|
|
181
|
+
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
182
|
+
connection.queuedBytes = (connection.queuedBytes || 0) + bytes;
|
|
183
|
+
if (connection.queuedBytes > 40 * 1024 * 1024) {
|
|
184
|
+
connection.ws.close(1013, 'Receiver too slow'); return;
|
|
185
|
+
}
|
|
186
|
+
connection.outbound = (connection.outbound || Promise.resolve()).then(async () => {
|
|
187
|
+
if (pause > 0) await new Promise(resolve => setTimeout(resolve, pause));
|
|
188
|
+
connection.queuedBytes -= bytes;
|
|
189
|
+
send(connection.ws, message);
|
|
190
|
+
}).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
|
|
191
|
+
};
|
|
90
192
|
ws.on('error', () => {}); // close owns routing cleanup.
|
|
91
193
|
ws.on('close', (code, reason) => {
|
|
92
194
|
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
93
195
|
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
94
196
|
if (role === 'host' && room?.host === ws) {
|
|
95
197
|
room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
|
|
198
|
+
room.uploadHost?.close(4410, 'Computer offline');
|
|
199
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
|
|
96
200
|
for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
|
|
97
201
|
if (!room.durable) rooms.delete(room.id);
|
|
98
202
|
for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
|
|
99
203
|
} else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
204
|
+
releaseRelay(room.connections.get(clientId));
|
|
100
205
|
room.connections.delete(clientId);
|
|
101
206
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
102
207
|
} else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
|
|
103
208
|
room.observers.delete(clientId);
|
|
104
|
-
}
|
|
209
|
+
} else if (role === 'file-host' && room?.uploadHost === ws) {
|
|
210
|
+
room.uploadHost = undefined;
|
|
211
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
|
|
212
|
+
} else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
|
|
213
|
+
room.uploadClients.delete(clientId);
|
|
214
|
+
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
|
|
215
|
+
}
|
|
105
216
|
});
|
|
106
217
|
ws.on('message', (bytes, binary) => {
|
|
107
218
|
try {
|
|
108
|
-
const message = decode(bytes, binary);
|
|
109
|
-
if (!role) {
|
|
219
|
+
const message = decode(bytes, binary);
|
|
220
|
+
if (!role) {
|
|
221
|
+
if (message.type === 'file-host') {
|
|
222
|
+
room = rooms.get(message.deviceId);
|
|
223
|
+
if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
|
|
224
|
+
role = 'file-host'; clearTimeout(authTimer);
|
|
225
|
+
room.uploadClients ||= new Map();
|
|
226
|
+
room.uploadHost?.close(4409, 'File receiver replaced');
|
|
227
|
+
room.uploadHost = ws;
|
|
228
|
+
send(ws, { type: 'file-host-ready' }); return;
|
|
229
|
+
}
|
|
230
|
+
if (message.type === 'file-client') {
|
|
231
|
+
room = rooms.get(message.room);
|
|
232
|
+
const client = room?.clients.find(item => item.id === message.clientId);
|
|
233
|
+
if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
|
|
234
|
+
!matches(message.credential, client.hash)) return fail();
|
|
235
|
+
room.uploadClients ||= new Map();
|
|
236
|
+
if (room.uploadClients.has(message.clientId) ||
|
|
237
|
+
[...rooms.values()].reduce((sum, item) => sum + (item.uploadClients?.size || 0), 0) >= 2) {
|
|
238
|
+
return fail(4429, 'Too many file uploads');
|
|
239
|
+
}
|
|
240
|
+
role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
|
|
241
|
+
room.uploadClients.set(clientId, { ws, session });
|
|
242
|
+
send(room.uploadHost, { type: 'file-open', session }); return;
|
|
243
|
+
}
|
|
110
244
|
if (message.type === 'host') {
|
|
111
245
|
const durable = message.deviceId !== undefined;
|
|
112
246
|
if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
|
|
@@ -117,20 +251,23 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
117
251
|
hostName = normalizeHostName(message.hostName);
|
|
118
252
|
}
|
|
119
253
|
catch { return fail(1008, 'Invalid LAN candidates'); }
|
|
120
|
-
room = durable ? rooms.get(message.deviceId) : undefined;
|
|
121
|
-
if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
122
|
-
if (!room) {
|
|
123
|
-
|
|
124
|
-
|
|
254
|
+
room = durable ? rooms.get(message.deviceId) : undefined;
|
|
255
|
+
if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
256
|
+
if (!room) {
|
|
257
|
+
// Durable computers prove future ownership with their generated device secret.
|
|
258
|
+
// The legacy deployment key remains accepted, but is no longer required for first registration.
|
|
259
|
+
if (!durable && !matches(message.key, hostHash)) return fail();
|
|
260
|
+
if (rooms.size >= 256) return fail(4429, 'Device limit reached');
|
|
125
261
|
const now = new Date().toISOString();
|
|
126
262
|
room = { id: durable ? message.deviceId : randomUUID(), durable,
|
|
127
|
-
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
|
|
263
|
+
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
|
|
128
264
|
...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
|
|
129
265
|
commit(room, {}); rooms.set(room.id, room);
|
|
130
266
|
}
|
|
131
267
|
const oldHost = room.host;
|
|
132
268
|
room.observers ||= new Map();
|
|
133
269
|
room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
|
|
270
|
+
if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
|
|
134
271
|
room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
|
|
135
272
|
role = 'host'; clearTimeout(authTimer);
|
|
136
273
|
if (oldHost && oldHost !== ws) {
|
|
@@ -143,7 +280,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
143
280
|
send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
144
281
|
}
|
|
145
282
|
const invite = room.clients.length ? {} : invitation(room);
|
|
146
|
-
send(ws, { type: 'registered', protocolVersion:
|
|
283
|
+
send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
|
|
147
284
|
clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
|
|
148
285
|
}
|
|
149
286
|
room = rooms.get(message.room);
|
|
@@ -177,6 +314,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
177
314
|
room.observers.set(clientId, { ws });
|
|
178
315
|
send(ws, { type: 'observing', online: Boolean(room.host) });
|
|
179
316
|
send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
317
|
+
const announcement = adminState.read().announcement;
|
|
318
|
+
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
180
319
|
if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
181
320
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
182
321
|
if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
|
|
@@ -186,14 +325,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
186
325
|
const oldConnection = room.connections.get(clientId);
|
|
187
326
|
role = 'client'; clearTimeout(authTimer); session = randomUUID();
|
|
188
327
|
if (oldConnection?.ws && oldConnection.ws !== ws) {
|
|
328
|
+
releaseRelay(oldConnection);
|
|
189
329
|
send(room.host, { type: 'detach', session: oldConnection.session });
|
|
190
330
|
oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
|
|
191
331
|
}
|
|
192
332
|
room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
|
|
193
|
-
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0
|
|
194
|
-
|
|
333
|
+
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
334
|
+
relayAdmitted: false, relayExempt: false,
|
|
335
|
+
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
336
|
+
send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
|
|
195
337
|
send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
196
338
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
339
|
+
const announcement = adminState.read().announcement;
|
|
340
|
+
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
197
341
|
send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
|
|
198
342
|
}
|
|
199
343
|
if (role === 'host') {
|
|
@@ -214,10 +358,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
214
358
|
commit(room, { clients: [], revision: room.revision + 1 });
|
|
215
359
|
room.pairingHash = undefined;
|
|
216
360
|
for (const connection of room.connections.values()) {
|
|
361
|
+
releaseRelay(connection);
|
|
217
362
|
send(ws, { type: 'detach', session: connection.session });
|
|
218
363
|
connection.ws.close(4401, 'Device authorization revoked');
|
|
219
364
|
}
|
|
220
365
|
room.connections.clear();
|
|
366
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
|
|
367
|
+
room.uploadClients?.clear();
|
|
221
368
|
for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
|
|
222
369
|
room.observers.clear();
|
|
223
370
|
send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
|
|
@@ -225,16 +372,30 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
225
372
|
}
|
|
226
373
|
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
227
374
|
if (!connection) return; // Late frames from an ended session.
|
|
228
|
-
if (message.type === 'data' && typeof message.payload === 'string')
|
|
229
|
-
else if (message.type === 'ready')
|
|
375
|
+
if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
|
|
376
|
+
else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
|
|
230
377
|
p2pProbeRepeat: connection.p2pProbe && connection.repeat });
|
|
231
378
|
else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
|
|
232
|
-
|
|
233
|
-
if (
|
|
379
|
+
const diagnostic = message.purpose === 'diagnostic';
|
|
380
|
+
if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
|
|
381
|
+
if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
|
|
382
|
+
if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
|
|
234
383
|
send(connection.ws, { ...message, session: undefined });
|
|
235
384
|
}
|
|
236
385
|
else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
|
|
237
386
|
else fail();
|
|
387
|
+
} else if (role === 'file-host' && room.uploadHost === ws) {
|
|
388
|
+
const client = [...room.uploadClients.values()].find(item => item.session === message.session);
|
|
389
|
+
if (!client) return;
|
|
390
|
+
if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
|
|
391
|
+
if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
|
|
392
|
+
send(client.ws, { type: 'file-frame', payload: message.payload });
|
|
393
|
+
} else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
|
|
394
|
+
else fail(1008, 'Invalid file frame');
|
|
395
|
+
} else if (role === 'file-client' && room.uploadClients?.get(clientId)?.ws === ws) {
|
|
396
|
+
if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
|
|
397
|
+
if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
|
|
398
|
+
send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
|
|
238
399
|
} else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
|
|
239
400
|
const computer = normalizeOfficialComputer(message);
|
|
240
401
|
commit(room, { officialComputer: computer });
|
|
@@ -242,16 +403,29 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
242
403
|
} else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
|
|
243
404
|
const connection = room.connections.get(clientId);
|
|
244
405
|
if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
|
|
406
|
+
const diagnostic = message.purpose === 'diagnostic';
|
|
407
|
+
if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
|
|
408
|
+
const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
|
|
409
|
+
const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
|
|
410
|
+
const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
|
|
245
411
|
if (message.kind === 'start') {
|
|
246
|
-
if (connection
|
|
247
|
-
send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
|
|
412
|
+
if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
|
|
413
|
+
send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
|
|
414
|
+
...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
|
|
248
415
|
}
|
|
249
|
-
connection
|
|
416
|
+
connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
|
|
250
417
|
}
|
|
251
|
-
if (connection
|
|
252
|
-
if (++connection
|
|
418
|
+
if (connection[idField] !== message.probeId) return;
|
|
419
|
+
if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
|
|
253
420
|
send(room.host, { ...message, session });
|
|
421
|
+
if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
|
|
422
|
+
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
|
|
423
|
+
releaseRelay(room.connections.get(clientId));
|
|
254
424
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|
|
425
|
+
const connection = room.connections.get(clientId);
|
|
426
|
+
if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
|
|
427
|
+
ws.close(4428, 'Server busy'); return;
|
|
428
|
+
}
|
|
255
429
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
256
430
|
} else fail();
|
|
257
431
|
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
@@ -261,7 +435,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
261
435
|
listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
|
|
262
436
|
return {
|
|
263
437
|
url: listenUrl, hostKey,
|
|
264
|
-
async close() {
|
|
438
|
+
async close() {
|
|
265
439
|
for (const ws of wss.clients) ws.terminate();
|
|
266
440
|
await new Promise(resolve => wss.close(resolve));
|
|
267
441
|
await new Promise(resolve => server.close(resolve));
|
|
@@ -338,3 +512,24 @@ function plainLabel(value) {
|
|
|
338
512
|
function plainText(value, limit, empty) {
|
|
339
513
|
return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
|
|
340
514
|
}
|
|
515
|
+
|
|
516
|
+
function controlPayload(value) {
|
|
517
|
+
try {
|
|
518
|
+
const message = JSON.parse(value);
|
|
519
|
+
if (message.type === 'unsubscribe') return true;
|
|
520
|
+
return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
|
|
521
|
+
(message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
|
|
522
|
+
} catch { return false; }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function readBody(request, limit) {
|
|
526
|
+
return new Promise((resolve, reject) => {
|
|
527
|
+
let value = '';
|
|
528
|
+
request.setEncoding('utf8');
|
|
529
|
+
request.on('data', chunk => {
|
|
530
|
+
value += chunk;
|
|
531
|
+
if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
|
|
532
|
+
});
|
|
533
|
+
request.on('end', () => resolve(value)); request.on('error', reject);
|
|
534
|
+
});
|
|
535
|
+
}
|