@walkhi/code-relax 0.1.0-beta.2 → 0.1.0-beta.20
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 +55 -71
- package/dist/bin/codex-remote.mjs +150 -126
- package/dist/bin/manage-relay-admin.mjs +20 -0
- package/dist/bin/migrate-relay-db.mjs +107 -0
- package/dist/bin/self-relay-admin.mjs +8 -0
- package/dist/bin/self-relay-demo.mjs +2 -2
- package/dist/bin/self-relay-server.mjs +18 -10
- package/dist/shared/app-server-events.cjs +42 -15
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/skills/code-relax/SKILL.md +7 -6
- package/dist/src/desktop-launcher.mjs +16 -21
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +52 -9
- package/dist/src/managed-app-server.mjs +44 -13
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +9 -11
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/background-process.mjs +1 -1
- package/dist/src/platform/windows/codex-runtime.mjs +251 -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-runtime.ps1 +10 -0
- 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-start.js +20 -0
- package/dist/src/platform/windows/resident-startup.ps1 +76 -20
- package/dist/src/platform/windows/service-host.mjs +4 -16
- package/dist/src/platform/windows/start-hidden-console.ps1 +10 -5
- package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
- package/dist/src/platform/windows/user-environment.mjs +1 -1
- 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 +79 -0
- package/dist/src/self-relay/admin-server.mjs +66 -0
- package/dist/src/self-relay/admin-state.mjs +82 -19
- package/dist/src/self-relay/admin-style.mjs +9 -0
- package/dist/src/self-relay/admin-usage-page.mjs +28 -0
- package/dist/src/self-relay/connector.mjs +243 -30
- package/dist/src/self-relay/demo.mjs +3 -4
- package/dist/src/self-relay/email-accounts.mjs +141 -0
- package/dist/src/self-relay/lifecycle.mjs +99 -142
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/p2p-probe.mjs +1 -1
- package/dist/src/self-relay/server.mjs +335 -59
- package/dist/src/self-relay/usage-diagnostics.mjs +46 -0
- package/dist/src/self-relay/usage-meter.mjs +125 -0
- package/dist/src/server.mjs +731 -1439
- package/dist/src/service-control.mjs +4 -4
- package/dist/src/service-doctor.mjs +27 -29
- package/dist/src/service-health.mjs +2 -2
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/service-lifecycle.mjs +19 -19
- package/dist/src/shared-app-server.mjs +277 -270
- package/dist/src/shared-catalog.mjs +1 -11
- 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 +17 -18
- package/tools/postinstall.mjs +69 -15
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -358
- 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 -20
- package/dist/src/thread-catalog.mjs +0 -47
- package/dist/tools/find-desktop-pipe.mjs +0 -10
- package/dist/web/activity-view.js +0 -283
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -57
- package/dist/web/chat-transport.js +0 -202
- package/dist/web/chat.css +0 -782
- package/dist/web/chat.js +0 -3830
- package/dist/web/client-platform.js +0 -11
- package/dist/web/community-view.js +0 -20
- package/dist/web/community.css +0 -77
- package/dist/web/community.html +0 -37
- package/dist/web/composer-controller.js +0 -101
- package/dist/web/conversation-controller.js +0 -99
- package/dist/web/disclosure-state-controller.js +0 -95
- package/dist/web/draft-controller.js +0 -103
- package/dist/web/harmony-platform.js +0 -52
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -240
- package/dist/web/history-controller.js +0 -167
- package/dist/web/index.html +0 -325
- package/dist/web/lan-session.js +0 -122
- package/dist/web/link-action-controller.js +0 -212
- package/dist/web/message-send-controller.js +0 -177
- package/dist/web/message-view.js +0 -98
- package/dist/web/p2p-data-channel.js +0 -50
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -123
- package/dist/web/page-resume.js +0 -28
- package/dist/web/pending-message-store.js +0 -108
- package/dist/web/queue-controller.js +0 -82
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -129
- package/dist/web/server-requests.js +0 -67
- package/dist/web/station-connection-controller.js +0 -75
- package/dist/web/task-list-view.js +0 -296
- package/dist/web/thread-attention-controller.js +0 -124
- package/dist/web/thread-context-controller.js +0 -30
- package/dist/web/thread-list-controller.js +0 -61
- package/dist/web/thread-list-sync.js +0 -86
- package/dist/web/thread-title-controller.js +0 -57
- package/dist/web/timeline-formatters.js +0 -249
- package/dist/web/timeline-reducer.js +0 -81
- package/dist/web/timeline-renderer.js +0 -161
- package/dist/web/timeline-scroll-controller.js +0 -50
- package/dist/web/timeline-sync.js +0 -67
- package/dist/web/usage-controller.js +0 -258
- 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
- package/dist/web/vendor/lucide.LICENSE.txt +0 -17
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
2
4
|
import { deviceStore, validId } from './device-store.mjs';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
6
|
import { WebSocketServer } from 'ws';
|
|
5
7
|
import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
6
8
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
7
9
|
import { validProbeSignal } from './p2p-probe.mjs';
|
|
8
10
|
import { relayAdminState } from './admin-state.mjs';
|
|
11
|
+
import { openRelayMysql } from './mysql-state.mjs';
|
|
12
|
+
import { adminPage } from './admin-page.mjs';
|
|
13
|
+
import { openEmailAccounts } from './email-accounts.mjs';
|
|
14
|
+
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
15
|
+
import { usageDiagnostics } from './usage-diagnostics.mjs';
|
|
9
16
|
|
|
10
17
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
11
18
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
12
19
|
maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
13
|
-
maxRelaySessions = 8, adminStateFile,
|
|
20
|
+
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
|
|
21
|
+
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
14
22
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
15
23
|
if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
|
|
16
24
|
if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
|
|
@@ -20,24 +28,39 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
20
28
|
const nativeOrigins = publicRelayUrl ? ['http:', 'https:'].map(protocol => `${protocol}//${new URL(publicRelayUrl).host}`) : [];
|
|
21
29
|
if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
|
|
22
30
|
const hostHash = digest(hostKey), rooms = new Map();
|
|
23
|
-
const
|
|
31
|
+
const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
|
|
32
|
+
if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
|
|
33
|
+
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
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');
|
|
39
|
+
const store = database?.devices || deviceStore(stateFile);
|
|
24
40
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
25
|
-
const adminState = relayAdminState(adminStateFile, maxRelaySessions);
|
|
41
|
+
const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
|
|
26
42
|
let activeRelaySessions = 0;
|
|
27
|
-
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
|
|
43
|
+
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
|
|
44
|
+
let commitTail = Promise.resolve();
|
|
28
45
|
function commit(room, changes) {
|
|
46
|
+
const operation = commitTail.then(async () => {
|
|
47
|
+
changes = typeof changes === 'function' ? changes(room) : changes;
|
|
29
48
|
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
30
49
|
if (room.durable) {
|
|
31
50
|
const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
|
|
32
|
-
store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
|
|
51
|
+
await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
|
|
33
52
|
}
|
|
34
|
-
Object.assign(room, changes, { updatedAt: next.updatedAt });
|
|
53
|
+
Object.assign(room, changes, { updatedAt: next.updatedAt });
|
|
54
|
+
});
|
|
55
|
+
commitTail = operation.catch(() => {});
|
|
56
|
+
return operation;
|
|
35
57
|
}
|
|
36
58
|
function invitation(room) {
|
|
37
59
|
const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
|
|
38
60
|
return { code, expires: room.expires };
|
|
39
61
|
}
|
|
40
62
|
let listenUrl = '';
|
|
63
|
+
let adminTail = Promise.resolve();
|
|
41
64
|
function relayUrl(request) {
|
|
42
65
|
if (publicRelayUrl) return relayPublicAddress(publicRelayUrl);
|
|
43
66
|
const host = request.headers.host || new URL(listenUrl).host;
|
|
@@ -51,7 +74,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
51
74
|
const server = http.createServer((request, response) => {
|
|
52
75
|
const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
53
76
|
if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
|
|
54
|
-
|
|
77
|
+
adminTail = adminTail.then(() => handleAdmin(request, response, target)).catch(() => {
|
|
78
|
+
if (!response.headersSent) adminReply(response, 500, { error: 'Admin operation failed' });
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (accounts && target.pathname.startsWith('/account/')) {
|
|
83
|
+
void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
|
|
84
|
+
return;
|
|
55
85
|
}
|
|
56
86
|
if (request.method === 'GET' && target.pathname === '/announcement') {
|
|
57
87
|
const body = JSON.stringify({ announcement: adminState.read().announcement });
|
|
@@ -82,30 +112,123 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
82
112
|
'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'" });
|
|
83
113
|
response.end(value);
|
|
84
114
|
}
|
|
115
|
+
function accountReply(response, status, body) {
|
|
116
|
+
if (response.headersSent || response.destroyed) return;
|
|
117
|
+
const value = JSON.stringify(body);
|
|
118
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(value),
|
|
119
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
120
|
+
response.end(value);
|
|
121
|
+
}
|
|
122
|
+
async function handleAccount(request, response, target) {
|
|
123
|
+
try {
|
|
124
|
+
if (target.pathname === '/account/config' && request.method === 'GET') {
|
|
125
|
+
accountReply(response, 200, { captchaAppId: accounts.captchaAppId }); return;
|
|
126
|
+
}
|
|
127
|
+
const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
|
|
128
|
+
if (target.pathname === '/account/me' && request.method === 'GET') {
|
|
129
|
+
const user = await accounts.current(bearer);
|
|
130
|
+
accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
|
|
131
|
+
}
|
|
132
|
+
if (target.pathname === '/account/logout' && request.method === 'POST') {
|
|
133
|
+
accountReply(response, 200, await accounts.logout(bearer)); return;
|
|
134
|
+
}
|
|
135
|
+
if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
|
|
136
|
+
accountReply(response, 404, { error: 'Account route not found' }); return;
|
|
137
|
+
}
|
|
138
|
+
const body = JSON.parse(await readBody(request, 4096));
|
|
139
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) { accountReply(response, 400, { error: 'Invalid request body' }); return; }
|
|
140
|
+
if (target.pathname === '/account/email/request') {
|
|
141
|
+
const ip = request.headers['x-real-ip'] || request.socket.remoteAddress || '';
|
|
142
|
+
if (typeof ip !== 'string' || !isIP(ip)) { accountReply(response, 400, { error: 'Invalid client address' }); return; }
|
|
143
|
+
accountReply(response, 202, await accounts.requestCode(body, ip));
|
|
144
|
+
} else if (target.pathname === '/account/email/verify') {
|
|
145
|
+
accountReply(response, 200, await accounts.verifyCode(body));
|
|
146
|
+
} else accountReply(response, 404, { error: 'Account route not found' });
|
|
147
|
+
} catch (failure) {
|
|
148
|
+
accountReply(response, failure.status || 503, { error: failure.status ? failure.message : 'Account service unavailable' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
85
151
|
async function handleAdmin(request, response, target) {
|
|
86
152
|
if (request.method === 'GET' && target.pathname === '/admin') {
|
|
87
153
|
adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
|
|
88
154
|
}
|
|
155
|
+
if (target.pathname.startsWith('/admin/api/')) {
|
|
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));
|
|
161
|
+
let email;
|
|
162
|
+
if (!keyAccepted) {
|
|
163
|
+
try { email = (await accounts.current(token)).email; }
|
|
164
|
+
catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
|
|
165
|
+
const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
|
|
166
|
+
if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
|
|
167
|
+
}
|
|
168
|
+
if (target.pathname === '/admin/api/session' && request.method === 'GET') {
|
|
169
|
+
adminReply(response, 200, { email }); return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
89
172
|
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
90
173
|
const state = adminState.read();
|
|
91
174
|
adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
|
|
92
175
|
activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
|
|
93
176
|
websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
|
|
94
|
-
activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
|
|
177
|
+
activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
|
|
178
|
+
processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
|
|
179
|
+
announcement: state.announcement, announcements: state.announcements }); return;
|
|
180
|
+
}
|
|
181
|
+
if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
|
|
182
|
+
const page = Number(target.searchParams.get('page') || '1');
|
|
183
|
+
const user = target.searchParams.get('user') || '';
|
|
184
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 100000 || (user && !/^[0-9a-f-]{36}$/.test(user))) {
|
|
185
|
+
adminReply(response, 400, { error: 'Invalid usage query' }); return;
|
|
186
|
+
}
|
|
187
|
+
adminReply(response, 200, await usageReport(database.pool, page, user)); return;
|
|
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;
|
|
95
199
|
}
|
|
96
200
|
if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
|
|
97
201
|
request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
|
|
98
202
|
try {
|
|
99
203
|
const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
|
|
100
204
|
if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
|
|
101
|
-
const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
205
|
+
const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
102
206
|
log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
|
|
103
|
-
} else if (target.pathname === '/admin/api/
|
|
104
|
-
const state = adminState.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
log({ event: 'admin-announcement-
|
|
207
|
+
} else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
|
|
208
|
+
const state = await adminState.createAnnouncement(body.title, body.message, body.publish === true);
|
|
209
|
+
if (body.publish === true) {
|
|
210
|
+
broadcastAnnouncement(state.announcement);
|
|
211
|
+
log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
|
|
212
|
+
} else log({ event: 'admin-announcement-draft-created', announcementId: state.announcements[0].id });
|
|
213
|
+
} else if (target.pathname.startsWith('/admin/api/announcements/')) {
|
|
214
|
+
const parts = target.pathname.slice('/admin/api/announcements/'.length).split('/');
|
|
215
|
+
const id = parts[0];
|
|
216
|
+
if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
217
|
+
if (parts.length === 1 && request.method === 'PUT') {
|
|
218
|
+
await adminState.updateDraft(id, body.title, body.message);
|
|
219
|
+
log({ event: 'admin-announcement-draft-updated', announcementId: id });
|
|
220
|
+
} else if (parts.length === 1 && request.method === 'DELETE') {
|
|
221
|
+
await adminState.deleteAnnouncement(id);
|
|
222
|
+
log({ event: 'admin-announcement-deleted', announcementId: id });
|
|
223
|
+
} else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
|
|
224
|
+
const state = await adminState.publishDraft(id, body.title, body.message);
|
|
225
|
+
broadcastAnnouncement(state.announcement);
|
|
226
|
+
log({ event: 'admin-announcement-published', announcementId: id });
|
|
227
|
+
} else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
|
|
228
|
+
const state = await adminState.withdraw(id);
|
|
229
|
+
broadcastAnnouncement(state.announcement);
|
|
230
|
+
log({ event: 'admin-announcement-withdrawn', announcementId: id });
|
|
231
|
+
} else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
109
232
|
} else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
110
233
|
adminReply(response, 200, { ok: true });
|
|
111
234
|
} catch (error) { adminReply(response, 400, { error: error.message }); }
|
|
@@ -168,26 +291,64 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
168
291
|
}).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
|
|
169
292
|
};
|
|
170
293
|
ws.on('error', () => {}); // close owns routing cleanup.
|
|
171
|
-
ws.on('close', (code, reason) => {
|
|
294
|
+
ws.on('close', (code, reason) => {
|
|
172
295
|
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
173
296
|
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
174
297
|
if (role === 'host' && room?.host === ws) {
|
|
298
|
+
for (const task of room.usageTasks?.values() || []) {
|
|
299
|
+
void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
|
|
300
|
+
}
|
|
301
|
+
room.usageTasks?.clear(); room.usageArms?.clear();
|
|
175
302
|
room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
|
|
303
|
+
room.uploadHost?.close(4410, 'Computer offline');
|
|
304
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
|
|
176
305
|
for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
|
|
177
306
|
if (!room.durable) rooms.delete(room.id);
|
|
178
307
|
for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
|
|
179
308
|
} else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
180
|
-
|
|
309
|
+
const connection = room.connections.get(clientId);
|
|
310
|
+
releaseRelay(connection);
|
|
181
311
|
room.connections.delete(clientId);
|
|
182
312
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
183
313
|
} else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
|
|
184
314
|
room.observers.delete(clientId);
|
|
185
|
-
}
|
|
315
|
+
} else if (role === 'file-host' && room?.uploadHost === ws) {
|
|
316
|
+
room.uploadHost = undefined;
|
|
317
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
|
|
318
|
+
} else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
|
|
319
|
+
room.uploadClients.delete(clientId);
|
|
320
|
+
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
|
|
321
|
+
}
|
|
186
322
|
});
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
323
|
+
let messageTail = Promise.resolve();
|
|
324
|
+
ws.on('message', (bytes, binary) => {
|
|
325
|
+
messageTail = messageTail.then(async () => {
|
|
326
|
+
try {
|
|
327
|
+
const message = decode(bytes, binary);
|
|
328
|
+
if (!role) {
|
|
329
|
+
if (message.type === 'file-host') {
|
|
330
|
+
room = rooms.get(message.deviceId);
|
|
331
|
+
if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
|
|
332
|
+
role = 'file-host'; clearTimeout(authTimer);
|
|
333
|
+
room.uploadClients ||= new Map();
|
|
334
|
+
room.uploadHost?.close(4409, 'File receiver replaced');
|
|
335
|
+
room.uploadHost = ws;
|
|
336
|
+
send(ws, { type: 'file-host-ready' }); return;
|
|
337
|
+
}
|
|
338
|
+
if (message.type === 'file-client') {
|
|
339
|
+
room = rooms.get(message.room);
|
|
340
|
+
const client = room?.clients.find(item => item.id === message.clientId);
|
|
341
|
+
if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
|
|
342
|
+
!matches(message.credential, client.hash)) return fail();
|
|
343
|
+
room.uploadClients ||= new Map();
|
|
344
|
+
if (room.uploadClients.has(message.clientId) ||
|
|
345
|
+
[...rooms.values()].reduce((sum, item) => sum + (item.uploadClients?.size || 0), 0) >= 2) {
|
|
346
|
+
return fail(4429, 'Too many file uploads');
|
|
347
|
+
}
|
|
348
|
+
role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
|
|
349
|
+
room.uploadClients.set(clientId, { ws, session });
|
|
350
|
+
send(room.uploadHost, { type: 'file-open', session }); return;
|
|
351
|
+
}
|
|
191
352
|
if (message.type === 'host') {
|
|
192
353
|
const durable = message.deviceId !== undefined;
|
|
193
354
|
if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
|
|
@@ -198,20 +359,30 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
198
359
|
hostName = normalizeHostName(message.hostName);
|
|
199
360
|
}
|
|
200
361
|
catch { return fail(1008, 'Invalid LAN candidates'); }
|
|
201
|
-
room = durable ? rooms.get(message.deviceId) : undefined;
|
|
202
|
-
if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
203
|
-
if (!room) {
|
|
204
|
-
|
|
205
|
-
|
|
362
|
+
room = durable ? rooms.get(message.deviceId) : undefined;
|
|
363
|
+
if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
|
|
364
|
+
if (!room) {
|
|
365
|
+
// Durable computers prove future ownership with their generated device secret.
|
|
366
|
+
// The legacy deployment key remains accepted, but is no longer required for first registration.
|
|
367
|
+
if (!durable && !matches(message.key, hostHash)) return fail();
|
|
368
|
+
if (rooms.size >= 256) return fail(4429, 'Device limit reached');
|
|
206
369
|
const now = new Date().toISOString();
|
|
207
370
|
room = { id: durable ? message.deviceId : randomUUID(), durable,
|
|
208
|
-
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
|
|
371
|
+
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
|
|
209
372
|
...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
|
|
210
|
-
commit(room, {}); rooms.set(room.id, room);
|
|
373
|
+
await commit(room, {}); rooms.set(room.id, room);
|
|
211
374
|
}
|
|
212
375
|
const oldHost = room.host;
|
|
213
376
|
room.observers ||= new Map();
|
|
377
|
+
if (oldHost && oldHost !== ws) {
|
|
378
|
+
for (const task of room.usageTasks?.values() || []) {
|
|
379
|
+
try { await meter?.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
380
|
+
}
|
|
381
|
+
room.usageTasks?.clear(); room.usageArms?.clear();
|
|
382
|
+
}
|
|
214
383
|
room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
|
|
384
|
+
room.usageTasks ||= new Map(); room.usageArms ||= new Map();
|
|
385
|
+
if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
|
|
215
386
|
room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
|
|
216
387
|
role = 'host'; clearTimeout(authTimer);
|
|
217
388
|
if (oldHost && oldHost !== ws) {
|
|
@@ -230,7 +401,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
230
401
|
room = rooms.get(message.room);
|
|
231
402
|
if (!room || room.status !== 'active') return fail();
|
|
232
403
|
let credential;
|
|
233
|
-
if (message.type === 'pair' && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
|
|
404
|
+
if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
|
|
234
405
|
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
235
406
|
credential = message.credential || secret();
|
|
236
407
|
clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
|
|
@@ -239,8 +410,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
239
410
|
if (!existing && room.clients.length >= maxClientsPerHost) return fail(4429, 'Mobile device limit reached');
|
|
240
411
|
const now = new Date().toISOString();
|
|
241
412
|
const client = { id: clientId, hash: digest(credential), createdAt: existing?.createdAt || now, updatedAt: now };
|
|
242
|
-
|
|
243
|
-
|
|
413
|
+
room.pairingInProgress = true;
|
|
414
|
+
try {
|
|
415
|
+
await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
|
|
416
|
+
room.pairingHash = undefined;
|
|
417
|
+
} finally { room.pairingInProgress = false; }
|
|
244
418
|
send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
|
|
245
419
|
} else {
|
|
246
420
|
clientId = message.clientId;
|
|
@@ -266,6 +440,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
266
440
|
return;
|
|
267
441
|
}
|
|
268
442
|
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
443
|
+
let userId;
|
|
444
|
+
if (accounts && message.accountToken !== undefined) {
|
|
445
|
+
try { userId = (await accounts.current(message.accountToken)).id; }
|
|
446
|
+
catch { return fail(4431, 'Account session invalid'); }
|
|
447
|
+
}
|
|
269
448
|
const oldConnection = room.connections.get(clientId);
|
|
270
449
|
role = 'client'; clearTimeout(authTimer); session = randomUUID();
|
|
271
450
|
if (oldConnection?.ws && oldConnection.ws !== ws) {
|
|
@@ -274,9 +453,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
274
453
|
oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
|
|
275
454
|
}
|
|
276
455
|
room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
|
|
456
|
+
userId,
|
|
277
457
|
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
278
458
|
relayAdmitted: false, relayExempt: false,
|
|
279
459
|
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
460
|
+
diagnostics?.record('client', userId ? 'account-linked' : 'anonymous');
|
|
280
461
|
send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
|
|
281
462
|
send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
282
463
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
@@ -286,6 +467,86 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
286
467
|
}
|
|
287
468
|
if (role === 'host') {
|
|
288
469
|
if (room.host !== ws) return;
|
|
470
|
+
if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
|
|
471
|
+
if (message.type === 'usage-observer-lost') {
|
|
472
|
+
for (const task of room.usageTasks.values()) {
|
|
473
|
+
try { await meter.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
474
|
+
}
|
|
475
|
+
room.usageTasks.clear();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const armKey = `${message.session}:${message.requestId}`;
|
|
479
|
+
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
480
|
+
if (message.type === 'usage-arm') {
|
|
481
|
+
diagnostics?.record('arm', connection?.userId ? 'account-linked' : 'anonymous');
|
|
482
|
+
if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
|
|
483
|
+
(message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
|
|
484
|
+
diagnostics?.record('arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
|
|
488
|
+
if (room.usageArms.size >= 256) return;
|
|
489
|
+
room.usageArms.set(armKey, { userId: connection.userId,
|
|
490
|
+
clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
|
|
491
|
+
session: message.session, at: Date.now() });
|
|
492
|
+
diagnostics?.record('arm-accepted', 'ready');
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
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
|
+
}
|
|
499
|
+
if (message.type === 'usage-message') {
|
|
500
|
+
const arm = room.usageArms.get(armKey);
|
|
501
|
+
diagnostics?.record('message', arm ? 'armed' : 'arm-missing');
|
|
502
|
+
room.usageArms.delete(armKey);
|
|
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
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
|
|
509
|
+
diagnostics?.record('message-counted', 'confirmed');
|
|
510
|
+
if (['start', 'steer'].includes(message.turnKind) &&
|
|
511
|
+
plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
|
|
512
|
+
const key = `${message.threadId}:${message.turnId}`;
|
|
513
|
+
if (key.length <= 128) {
|
|
514
|
+
const counted = await meter.action(arm, room.id, arm.clientId, 'turn', key);
|
|
515
|
+
if (counted) diagnostics?.record('turn-counted', 'confirmed');
|
|
516
|
+
if (!room.usageTasks.has(key) && message.observerReady === true && message.completed !== true &&
|
|
517
|
+
(message.turnKind === 'start' || message.active === true)) {
|
|
518
|
+
const task = await meter.turnOrigin(room.id, key);
|
|
519
|
+
if (task) {
|
|
520
|
+
await meter.pulse(task, true, room.id, task.clientId, key);
|
|
521
|
+
room.usageTasks.set(key, task);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} catch (error) { diagnostics?.record('message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
|
|
530
|
+
!plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
|
|
531
|
+
const key = `${message.threadId}:${message.turnId}`;
|
|
532
|
+
if (key.length > 128) return;
|
|
533
|
+
let task = room.usageTasks.get(key);
|
|
534
|
+
try {
|
|
535
|
+
if (message.method === 'resume' && !task) {
|
|
536
|
+
task = await meter.turnOrigin(room.id, key);
|
|
537
|
+
if (!task) { diagnostics?.record('turn-resume', 'origin-missing'); return; }
|
|
538
|
+
await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
|
|
539
|
+
diagnostics?.record('turn-resume', 'confirmed');
|
|
540
|
+
room.usageTasks.set(key, task);
|
|
541
|
+
} else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
|
|
542
|
+
else if (task && message.method === 'turn/completed') {
|
|
543
|
+
await meter.pulse(task, false, room.id, task.clientId);
|
|
544
|
+
diagnostics?.record('turn-completed', 'confirmed');
|
|
545
|
+
room.usageTasks.delete(key);
|
|
546
|
+
}
|
|
547
|
+
} catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
289
550
|
if (message.type === 'lan-candidates') {
|
|
290
551
|
room.lanCandidates = normalizeLanCandidates(message.candidates);
|
|
291
552
|
for (const connection of room.connections.values()) send(connection.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
@@ -299,7 +560,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
299
560
|
send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
|
|
300
561
|
}
|
|
301
562
|
if (message.type === 'revoke') {
|
|
302
|
-
commit(room, { clients: [], revision:
|
|
563
|
+
await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
|
|
303
564
|
room.pairingHash = undefined;
|
|
304
565
|
for (const connection of room.connections.values()) {
|
|
305
566
|
releaseRelay(connection);
|
|
@@ -307,6 +568,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
307
568
|
connection.ws.close(4401, 'Device authorization revoked');
|
|
308
569
|
}
|
|
309
570
|
room.connections.clear();
|
|
571
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
|
|
572
|
+
room.uploadClients?.clear();
|
|
310
573
|
for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
|
|
311
574
|
room.observers.clear();
|
|
312
575
|
send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
|
|
@@ -318,28 +581,49 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
318
581
|
else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
|
|
319
582
|
p2pProbeRepeat: connection.p2pProbe && connection.repeat });
|
|
320
583
|
else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
|
|
321
|
-
|
|
322
|
-
if (
|
|
584
|
+
const diagnostic = message.purpose === 'diagnostic';
|
|
585
|
+
if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
|
|
586
|
+
if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
|
|
587
|
+
if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
|
|
323
588
|
send(connection.ws, { ...message, session: undefined });
|
|
324
589
|
}
|
|
325
590
|
else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
|
|
326
591
|
else fail();
|
|
592
|
+
} else if (role === 'file-host' && room.uploadHost === ws) {
|
|
593
|
+
const client = [...room.uploadClients.values()].find(item => item.session === message.session);
|
|
594
|
+
if (!client) return;
|
|
595
|
+
if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
|
|
596
|
+
if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
|
|
597
|
+
send(client.ws, { type: 'file-frame', payload: message.payload });
|
|
598
|
+
} else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
|
|
599
|
+
else fail(1008, 'Invalid file frame');
|
|
600
|
+
} else if (role === 'file-client' && room.uploadClients?.get(clientId)?.ws === ws) {
|
|
601
|
+
if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
|
|
602
|
+
if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
|
|
603
|
+
send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
|
|
327
604
|
} else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
|
|
328
605
|
const computer = normalizeOfficialComputer(message);
|
|
329
|
-
commit(room, { officialComputer: computer });
|
|
606
|
+
await commit(room, { officialComputer: computer });
|
|
330
607
|
send(ws, { type: 'official-computer-saved', computer });
|
|
331
608
|
} else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
|
|
332
609
|
const connection = room.connections.get(clientId);
|
|
333
610
|
if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
|
|
611
|
+
const diagnostic = message.purpose === 'diagnostic';
|
|
612
|
+
if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
|
|
613
|
+
const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
|
|
614
|
+
const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
|
|
615
|
+
const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
|
|
334
616
|
if (message.kind === 'start') {
|
|
335
|
-
if (connection
|
|
336
|
-
send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
|
|
617
|
+
if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
|
|
618
|
+
send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
|
|
619
|
+
...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
|
|
337
620
|
}
|
|
338
|
-
connection
|
|
621
|
+
connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
|
|
339
622
|
}
|
|
340
|
-
if (connection
|
|
341
|
-
if (++connection
|
|
623
|
+
if (connection[idField] !== message.probeId) return;
|
|
624
|
+
if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
|
|
342
625
|
send(room.host, { ...message, session });
|
|
626
|
+
if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
|
|
343
627
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
|
|
344
628
|
releaseRelay(room.connections.get(clientId));
|
|
345
629
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|
|
@@ -349,17 +633,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
349
633
|
}
|
|
350
634
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
351
635
|
} else fail();
|
|
352
|
-
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
353
|
-
|
|
636
|
+
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
637
|
+
}).catch(() => ws.close(1011, 'Relay operation failed'));
|
|
638
|
+
});
|
|
354
639
|
});
|
|
355
640
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
356
641
|
listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
|
|
357
642
|
return {
|
|
358
643
|
url: listenUrl, hostKey,
|
|
359
|
-
async close() {
|
|
360
|
-
for (const ws of wss.clients) ws.terminate();
|
|
361
|
-
await new Promise(resolve => wss.close(resolve));
|
|
362
|
-
await new Promise(resolve => server.close(resolve));
|
|
644
|
+
async close() {
|
|
645
|
+
for (const ws of wss.clients) ws.terminate();
|
|
646
|
+
await new Promise(resolve => wss.close(resolve));
|
|
647
|
+
await new Promise(resolve => server.close(resolve));
|
|
648
|
+
if (database) await database.close();
|
|
363
649
|
},
|
|
364
650
|
};
|
|
365
651
|
}
|
|
@@ -454,13 +740,3 @@ function readBody(request, limit) {
|
|
|
454
740
|
request.on('end', () => resolve(value)); request.on('error', reject);
|
|
455
741
|
});
|
|
456
742
|
}
|
|
457
|
-
|
|
458
|
-
function adminPage() {
|
|
459
|
-
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Code Relax 管理</title><style>
|
|
460
|
-
body{margin:0;background:#0d1210;color:#eef4f0;font:15px system-ui,sans-serif}main{max-width:760px;margin:40px auto;padding:0 20px}section{background:#151d19;border:1px solid #2a3931;border-radius:14px;padding:18px;margin:16px 0}h1{font-size:24px}h2{font-size:17px}label{display:block;margin:12px 0 6px;color:#aab7af}input,textarea{box-sizing:border-box;width:100%;padding:11px;border:1px solid #43564b;border-radius:9px;background:#0d1210;color:#eef4f0}textarea{min-height:120px;resize:vertical}button{margin:12px 8px 0 0;padding:10px 16px;border:0;border-radius:9px;background:#2c7653;color:white}button.secondary{background:#39443e}pre{white-space:pre-wrap;color:#b9c8bf}.row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric{padding:12px;background:#0d1210;border-radius:9px}.metric b{display:block;font-size:22px}</style><main><h1>Code Relax 管理</h1><section><h2>运行状态</h2><div id="metrics" class="row"></div><button onclick="load()">刷新</button></section><section><h2>服务器转发容量</h2><label>最大并发 WSS 转发会话</label><input id="limit" type="number" min="1" max="256"><button onclick="saveLimit()">保存</button></section><section><h2>系统公告</h2><label>标题</label><input id="title" maxlength="80"><label>正文</label><textarea id="message" maxlength="2000"></textarea><button onclick="publish()">发布</button><button class="secondary" onclick="withdraw()">撤下</button><pre id="current"></pre></section><pre id="result"></pre><script>
|
|
461
|
-
const request=async(path,options={})=>{const response=await fetch(path,options);const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
|
|
462
|
-
async function load(){try{const s=await request('/admin/api/status');limit.value=s.maxRelaySessions;metrics.innerHTML='<div class="metric"><b>'+s.activeRelaySessions+'</b>受限转发 / '+s.maxRelaySessions+'</div><div class="metric"><b>'+s.activeExemptRelaySessions+'</b>白名单转发</div><div class="metric"><b>'+s.websocketConnections+'</b>WebSocket</div><div class="metric"><b>'+s.onlineComputers+'</b>在线工作站</div><div class="metric"><b>'+s.activePhones+'</b>活动手机</div>';current.textContent=s.announcement?s.announcement.title+'\\n'+s.announcement.message+'\\n'+s.announcement.publishedAt:'当前无公告';}catch(e){result.textContent=e.message}}
|
|
463
|
-
const write=(method,path,body)=>request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)}).then(()=>{result.textContent='已保存';return load()}).catch(e=>result.textContent=e.message);
|
|
464
|
-
function saveLimit(){return write('PUT','/admin/api/settings',{maxRelaySessions:Number(limit.value)})}function publish(){return write('PUT','/admin/api/announcement',{title:title.value,message:message.value})}function withdraw(){return write('DELETE','/admin/api/announcement',{})}load();
|
|
465
|
-
</script></main></html>`;
|
|
466
|
-
}
|
|
@@ -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
|
+
}
|