@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.10
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 +14 -15
- package/dist/bin/codex-remote.mjs +81 -26
- 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 +50 -22
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +11 -11
- package/dist/src/lan-socket-server.mjs +8 -2
- package/dist/src/managed-app-server.mjs +27 -7
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +3 -5
- 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 +16 -1
- package/dist/src/platform/windows/desktop-info.ps1 +2 -4
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
- 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 -7
- 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 +23 -8
- package/dist/src/self-relay/demo.mjs +4 -5
- package/dist/src/self-relay/lifecycle.mjs +53 -49
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +157 -17
- package/dist/src/server.mjs +484 -1274
- package/dist/src/service-doctor.mjs +13 -25
- package/dist/src/service-lifecycle.mjs +12 -12
- package/dist/src/shared-app-server.mjs +192 -34
- package/dist/src/shared-catalog.mjs +4 -14
- package/dist/src/shared-recovery.mjs +8 -6
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/web/activity-view.js +283 -0
- package/dist/web/capabilities.js +2 -9
- package/dist/web/chat-transport.js +21 -21
- package/dist/web/chat.css +148 -106
- package/dist/web/chat.js +1454 -2788
- package/dist/web/community.css +82 -0
- package/dist/web/community.html +36 -0
- package/dist/web/composer-controller.js +97 -0
- package/dist/web/conversation-controller.js +99 -0
- package/dist/web/disclosure-state-controller.js +95 -0
- package/dist/web/draft-controller.js +103 -0
- package/dist/web/harmony-platform.js +3 -2
- package/dist/web/history-cache.js +112 -18
- package/dist/web/history-controller.js +167 -0
- package/dist/web/index.html +113 -47
- package/dist/web/link-action-controller.js +212 -0
- package/dist/web/message-send-controller.js +177 -0
- package/dist/web/message-view.js +98 -0
- package/dist/web/p2p-data-channel.js +50 -0
- package/dist/web/p2p-probe.js +67 -27
- package/dist/web/page-resume.js +28 -0
- package/dist/web/pending-message-store.js +108 -0
- package/dist/web/queue-controller.js +82 -0
- package/dist/web/resources.json +1 -1
- package/dist/web/self-relay-session.js +28 -18
- package/dist/web/station-connection-controller.js +75 -0
- package/dist/web/task-list-view.js +289 -0
- package/dist/web/thread-attention-controller.js +124 -0
- package/dist/web/thread-context-controller.js +30 -0
- package/dist/web/thread-list-controller.js +61 -0
- package/dist/web/thread-list-sync.js +86 -0
- package/dist/web/thread-title-controller.js +57 -0
- package/dist/web/timeline-formatters.js +249 -0
- package/dist/web/timeline-reducer.js +77 -0
- package/dist/web/timeline-renderer.js +161 -0
- package/dist/web/timeline-scroll-controller.js +50 -0
- package/dist/web/usage-controller.js +258 -0
- package/dist/web/vendor/lucide.LICENSE.txt +17 -0
- 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/platform/windows/desktop-monitor.mjs +0 -34
- 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
|
@@ -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,6 +23,8 @@ 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);
|
|
26
|
+
const adminState = relayAdminState(adminStateFile, maxRelaySessions);
|
|
27
|
+
let activeRelaySessions = 0;
|
|
23
28
|
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
|
|
24
29
|
function commit(room, changes) {
|
|
25
30
|
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
@@ -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,12 +171,24 @@ 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() });
|
|
@@ -97,6 +199,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
97
199
|
if (!room.durable) rooms.delete(room.id);
|
|
98
200
|
for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
|
|
99
201
|
} else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
202
|
+
releaseRelay(room.connections.get(clientId));
|
|
100
203
|
room.connections.delete(clientId);
|
|
101
204
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
102
205
|
} else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
|
|
@@ -117,11 +220,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
117
220
|
hostName = normalizeHostName(message.hostName);
|
|
118
221
|
}
|
|
119
222
|
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
|
-
|
|
223
|
+
room = durable ? rooms.get(message.deviceId) : undefined;
|
|
224
|
+
if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
225
|
+
if (!room) {
|
|
226
|
+
// Durable computers prove future ownership with their generated device secret.
|
|
227
|
+
// The legacy deployment key remains accepted, but is no longer required for first registration.
|
|
228
|
+
if (!durable && !matches(message.key, hostHash)) return fail();
|
|
229
|
+
if (rooms.size >= 256) return fail(4429, 'Device limit reached');
|
|
125
230
|
const now = new Date().toISOString();
|
|
126
231
|
room = { id: durable ? message.deviceId : randomUUID(), durable,
|
|
127
232
|
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
|
|
@@ -143,7 +248,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
143
248
|
send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
144
249
|
}
|
|
145
250
|
const invite = room.clients.length ? {} : invitation(room);
|
|
146
|
-
send(ws, { type: 'registered', protocolVersion:
|
|
251
|
+
send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
|
|
147
252
|
clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
|
|
148
253
|
}
|
|
149
254
|
room = rooms.get(message.room);
|
|
@@ -177,6 +282,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
177
282
|
room.observers.set(clientId, { ws });
|
|
178
283
|
send(ws, { type: 'observing', online: Boolean(room.host) });
|
|
179
284
|
send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
285
|
+
const announcement = adminState.read().announcement;
|
|
286
|
+
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
180
287
|
if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
181
288
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
182
289
|
if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
|
|
@@ -186,14 +293,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
186
293
|
const oldConnection = room.connections.get(clientId);
|
|
187
294
|
role = 'client'; clearTimeout(authTimer); session = randomUUID();
|
|
188
295
|
if (oldConnection?.ws && oldConnection.ws !== ws) {
|
|
296
|
+
releaseRelay(oldConnection);
|
|
189
297
|
send(room.host, { type: 'detach', session: oldConnection.session });
|
|
190
298
|
oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
|
|
191
299
|
}
|
|
192
300
|
room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
|
|
193
|
-
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0
|
|
194
|
-
|
|
301
|
+
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
302
|
+
relayAdmitted: false, relayExempt: false,
|
|
303
|
+
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
304
|
+
send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
|
|
195
305
|
send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
196
306
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
307
|
+
const announcement = adminState.read().announcement;
|
|
308
|
+
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
197
309
|
send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
|
|
198
310
|
}
|
|
199
311
|
if (role === 'host') {
|
|
@@ -214,6 +326,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
214
326
|
commit(room, { clients: [], revision: room.revision + 1 });
|
|
215
327
|
room.pairingHash = undefined;
|
|
216
328
|
for (const connection of room.connections.values()) {
|
|
329
|
+
releaseRelay(connection);
|
|
217
330
|
send(ws, { type: 'detach', session: connection.session });
|
|
218
331
|
connection.ws.close(4401, 'Device authorization revoked');
|
|
219
332
|
}
|
|
@@ -225,8 +338,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
225
338
|
}
|
|
226
339
|
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
227
340
|
if (!connection) return; // Late frames from an ended session.
|
|
228
|
-
if (message.type === 'data' && typeof message.payload === 'string')
|
|
229
|
-
else if (message.type === 'ready')
|
|
341
|
+
if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
|
|
342
|
+
else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
|
|
230
343
|
p2pProbeRepeat: connection.p2pProbe && connection.repeat });
|
|
231
344
|
else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
|
|
232
345
|
if (connection.probeId !== message.probeId) return;
|
|
@@ -251,7 +364,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
251
364
|
if (connection.probeId !== message.probeId) return;
|
|
252
365
|
if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
|
|
253
366
|
send(room.host, { ...message, session });
|
|
367
|
+
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
|
|
368
|
+
releaseRelay(room.connections.get(clientId));
|
|
254
369
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|
|
370
|
+
const connection = room.connections.get(clientId);
|
|
371
|
+
if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
|
|
372
|
+
ws.close(4428, 'Server busy'); return;
|
|
373
|
+
}
|
|
255
374
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
256
375
|
} else fail();
|
|
257
376
|
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
@@ -261,7 +380,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
261
380
|
listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
|
|
262
381
|
return {
|
|
263
382
|
url: listenUrl, hostKey,
|
|
264
|
-
async close() {
|
|
383
|
+
async close() {
|
|
265
384
|
for (const ws of wss.clients) ws.terminate();
|
|
266
385
|
await new Promise(resolve => wss.close(resolve));
|
|
267
386
|
await new Promise(resolve => server.close(resolve));
|
|
@@ -338,3 +457,24 @@ function plainLabel(value) {
|
|
|
338
457
|
function plainText(value, limit, empty) {
|
|
339
458
|
return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
|
|
340
459
|
}
|
|
460
|
+
|
|
461
|
+
function controlPayload(value) {
|
|
462
|
+
try {
|
|
463
|
+
const message = JSON.parse(value);
|
|
464
|
+
if (message.type === 'unsubscribe') return true;
|
|
465
|
+
return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
|
|
466
|
+
(message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
|
|
467
|
+
} catch { return false; }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function readBody(request, limit) {
|
|
471
|
+
return new Promise((resolve, reject) => {
|
|
472
|
+
let value = '';
|
|
473
|
+
request.setEncoding('utf8');
|
|
474
|
+
request.on('data', chunk => {
|
|
475
|
+
value += chunk;
|
|
476
|
+
if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
|
|
477
|
+
});
|
|
478
|
+
request.on('end', () => resolve(value)); request.on('error', reject);
|
|
479
|
+
});
|
|
480
|
+
}
|