@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,9 +1,9 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { execFile } from 'node:child_process';
|
|
4
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
-
import { startBackground, processAlive } from '../background-process.mjs';
|
|
6
|
-
import { promisify } from 'node:util';
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { startBackground, processAlive } from '../background-process.mjs';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
7
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { hostname } from 'node:os';
|
|
@@ -16,45 +16,49 @@ import { connectBridgeRelay } from './connector.mjs';
|
|
|
16
16
|
import { validProbeIceServers } from './p2p-probe.mjs';
|
|
17
17
|
import { relayAddress, validSecret } from './wire.mjs';
|
|
18
18
|
import { saveLanAuthorizations } from './lan-authorizations.mjs';
|
|
19
|
-
import { lanNetworks } from '../platform/windows/network-info.mjs';
|
|
20
|
-
|
|
21
|
-
const exec = promisify(execFile);
|
|
22
|
-
const script = fileURLToPath(import.meta.url);
|
|
23
|
-
const recordPath = root => path.join(root, 'self-relay-session.json');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
import { lanNetworks } from '../platform/windows/network-info.mjs';
|
|
20
|
+
|
|
21
|
+
const exec = promisify(execFile);
|
|
22
|
+
const script = fileURLToPath(import.meta.url);
|
|
23
|
+
const recordPath = root => path.join(root, 'self-relay-session.json');
|
|
24
|
+
const officialConfig = { url: 'wss://api.uulife.site/relay' };
|
|
25
|
+
|
|
26
|
+
async function loadConfig(stateRoot) {
|
|
27
|
+
const configPath = path.join(stateRoot, 'self-relay-config.json');
|
|
28
|
+
const stored = readRecord(configPath) || {};
|
|
29
|
+
const config = { ...officialConfig, ...stored };
|
|
30
|
+
if (!stored.url) writeRecord(configPath, config);
|
|
27
31
|
if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
|
|
28
32
|
if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
|
|
29
33
|
if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
|
|
30
34
|
const url = process.env.RELAY_URL || config.url;
|
|
31
|
-
if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
|
|
35
|
+
if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
|
|
32
36
|
relayAddress(url);
|
|
33
37
|
const resolved = relayAddress(url);
|
|
34
38
|
const pairingUrl = config.pairingUrl || `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`;
|
|
35
39
|
const pairing = new URL(pairingUrl);
|
|
36
40
|
if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
|
|
37
41
|
(pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
|
|
38
|
-
return { ...config, url: resolved, pairingUrl: pairing.href };
|
|
39
|
-
}
|
|
40
|
-
async function registrationKey(config) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export async function selfRelay(command, stateRoot
|
|
42
|
+
return { ...config, url: resolved, pairingUrl: pairing.href };
|
|
43
|
+
}
|
|
44
|
+
async function registrationKey(config) {
|
|
45
|
+
if (config.url === officialConfig.url) return undefined;
|
|
46
|
+
const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
|
|
47
|
+
let hostKey;
|
|
48
|
+
if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
|
|
49
|
+
else {
|
|
50
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
|
|
51
|
+
!/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
|
|
52
|
+
try {
|
|
53
|
+
const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
|
|
54
|
+
`sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
|
|
55
|
+
hostKey = result.stdout.trim();
|
|
56
|
+
} catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
|
|
57
|
+
}
|
|
58
|
+
if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
|
|
59
|
+
return hostKey;
|
|
60
|
+
}
|
|
61
|
+
export async function selfRelay(command, stateRoot) {
|
|
58
62
|
const previous = readRecord(recordPath(stateRoot));
|
|
59
63
|
let current;
|
|
60
64
|
if (previous) {
|
|
@@ -73,22 +77,18 @@ export async function selfRelay(command, stateRoot, requiredMode = '') {
|
|
|
73
77
|
return { stopped: true, displayText: '私有中继连接器已停止;Bridge 和 Codex 保持运行。' };
|
|
74
78
|
}
|
|
75
79
|
if (current) {
|
|
76
|
-
if (requiredMode) {
|
|
77
|
-
const bridge = await serviceHealth(readRecord(path.join(stateRoot, 'bridge-session.json')));
|
|
78
|
-
if (bridge?.transportMode !== requiredMode) throw new Error('当前 Bridge 未启用完整控制;请先执行 shared-start。');
|
|
79
|
-
}
|
|
80
80
|
if (command === 'relay-revoke') return controlRequest(previous, 'POST', 'revoke');
|
|
81
81
|
if (command === 'relay-pair' || (current.relayConnected && !current.paired && !current.qrPath)) return controlRequest(previous, 'POST', 'pair');
|
|
82
82
|
if (command === 'relay-start' && current.relayConnected) return controlRequest(previous, 'POST', 'refresh-lan');
|
|
83
83
|
return current;
|
|
84
84
|
}
|
|
85
|
-
if (command === 'relay-revoke') throw new Error('
|
|
85
|
+
if (command === 'relay-revoke') throw new Error('请先启动连接器,再撤销移动端授权。');
|
|
86
86
|
fs.mkdirSync(stateRoot, { recursive: true });
|
|
87
87
|
const instanceId = randomUUID();
|
|
88
88
|
const log = path.join(stateRoot, 'self-relay.log');
|
|
89
89
|
const readyPath = path.join(stateRoot, `relay-ready-${instanceId}.json`);
|
|
90
90
|
const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, instanceId,
|
|
91
|
-
command === 'relay-pair' ? 'pair' : ''
|
|
91
|
+
command === 'relay-pair' ? 'pair' : ''], process.env, [log, log]);
|
|
92
92
|
try {
|
|
93
93
|
const deadline = Date.now() + 25000;
|
|
94
94
|
while (true) {
|
|
@@ -106,16 +106,13 @@ export async function selfRelay(command, stateRoot, requiredMode = '') {
|
|
|
106
106
|
finally { fs.rmSync(readyPath, { force: true }); }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
async function worker(stateRoot, instanceId, refresh = false
|
|
109
|
+
async function worker(stateRoot, instanceId, refresh = false) {
|
|
110
110
|
const config = await loadConfig(stateRoot);
|
|
111
111
|
const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
112
112
|
let health = await serviceHealth(session());
|
|
113
113
|
if (health?.status !== 'ok') throw new Error('Bridge 尚未运行,请先执行 relax start --json。');
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const legacy = readRecord(legacyPath);
|
|
117
|
-
const stored = await credentialStore('load');
|
|
118
|
-
let identities = stored.record || legacy || { version: 1, servers: {} };
|
|
114
|
+
const stored = await credentialStore('load');
|
|
115
|
+
const identities = stored.record || { version: 1, servers: {} };
|
|
119
116
|
const validate = value => {
|
|
120
117
|
if (value?.version !== 1 || !value.servers || typeof value.servers !== 'object' || Array.isArray(value.servers)) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
121
118
|
for (const [url, item] of Object.entries(value.servers)) {
|
|
@@ -124,25 +121,16 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
124
121
|
}
|
|
125
122
|
};
|
|
126
123
|
validate(identities);
|
|
127
|
-
if (legacy) {
|
|
128
|
-
validate(legacy);
|
|
129
|
-
if (stored.record && JSON.stringify(stored.record) !== JSON.stringify(legacy)) throw new Error('新旧设备身份冲突,未覆盖任何凭据');
|
|
130
|
-
if (!stored.record) {
|
|
131
|
-
const verified = await credentialStore('save', identities);
|
|
132
|
-
if (JSON.stringify(verified.record) !== JSON.stringify(identities)) throw new Error('凭据迁移校验失败');
|
|
133
|
-
}
|
|
134
|
-
fs.rmSync(legacyPath);
|
|
135
|
-
}
|
|
136
124
|
let identity = identities.servers[config.url];
|
|
137
|
-
if (!identity) {
|
|
138
|
-
identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
|
|
139
|
-
identities.servers[config.url] = identity; await credentialStore('save', identities);
|
|
140
|
-
}
|
|
141
|
-
const hostKey = identity.registered ? undefined : await registrationKey(config);
|
|
142
|
-
let bridgeError = '';
|
|
125
|
+
if (!identity) {
|
|
126
|
+
identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
|
|
127
|
+
identities.servers[config.url] = identity; await credentialStore('save', identities);
|
|
128
|
+
}
|
|
129
|
+
const hostKey = identity.registered ? undefined : await registrationKey(config);
|
|
130
|
+
let bridgeError = '';
|
|
143
131
|
async function refreshHealth() {
|
|
144
|
-
try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline'
|
|
145
|
-
catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline'
|
|
132
|
+
try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
|
|
133
|
+
catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
|
|
146
134
|
}
|
|
147
135
|
const secret = randomBytes(32).toString('base64url');
|
|
148
136
|
let connector, control, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
|
|
@@ -159,15 +147,15 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
159
147
|
const status = () => {
|
|
160
148
|
const available = online && !paired && qrPath && Date.now() < expiresAt;
|
|
161
149
|
const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
|
|
162
|
-
: paired ? '
|
|
150
|
+
: paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
|
|
163
151
|
: available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
|
|
164
152
|
: '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
|
|
165
153
|
return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
|
|
166
154
|
relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
|
|
167
|
-
bridgeReady: health.status === 'ok' &&
|
|
155
|
+
bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
|
|
168
156
|
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
169
157
|
...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
|
|
170
|
-
displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? '
|
|
158
|
+
displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}\n停止连接器保留授权:relax relay-stop --json。撤销全部移动设备:relax relay-revoke --json。` };
|
|
171
159
|
};
|
|
172
160
|
async function shutdown() {
|
|
173
161
|
if (closing) return;
|
|
@@ -199,7 +187,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
199
187
|
await refreshHealth();
|
|
200
188
|
const lanCandidates = await currentLanCandidates();
|
|
201
189
|
connector?.events.removeAllListeners(); connector?.close();
|
|
202
|
-
const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
|
|
190
|
+
const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
|
|
203
191
|
bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
|
|
204
192
|
bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
|
|
205
193
|
hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
|
|
@@ -219,7 +207,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
219
207
|
if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
|
|
220
208
|
});
|
|
221
209
|
const registration = await current.ready;
|
|
222
|
-
if (registration.protocolVersion !==
|
|
210
|
+
if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
|
|
223
211
|
current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
|
|
224
212
|
}
|
|
225
213
|
if (!identity.registered) {
|
|
@@ -274,7 +262,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
274
262
|
}
|
|
275
263
|
|
|
276
264
|
if (process.argv[1] && path.resolve(process.argv[1]) === script && process.argv[2] === '--worker') {
|
|
277
|
-
worker(process.argv[3], process.argv[4], process.argv[5] === 'pair'
|
|
265
|
+
worker(process.argv[3], process.argv[4], process.argv[5] === 'pair').catch(error => {
|
|
278
266
|
writeRecord(path.join(process.argv[3], `relay-ready-${process.argv[4]}.json`), { instanceId: process.argv[4], pid: process.pid, error: error.message });
|
|
279
267
|
console.error(error.message); process.exitCode = 1;
|
|
280
268
|
});
|
|
@@ -1,83 +1,123 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import P2pDiagnostics from '../../shared/p2p-diagnostics.cjs';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const PROBE_UDP_PORT_RANGE = Object.freeze({ begin: 42000, end: 42031 });
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
} catch { diagnostic.error = '
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import P2pDiagnostics from '../../shared/p2p-diagnostics.cjs';
|
|
3
|
+
import P2pDataFramer from '../../shared/p2p-data-channel.cjs';
|
|
4
|
+
|
|
5
|
+
const PROBE_UDP_PORT_RANGE = Object.freeze({ begin: 42000, end: 42031 });
|
|
6
|
+
const HIGH_WATER = 1024 * 1024;
|
|
7
|
+
const LOW_WATER = 512 * 1024;
|
|
8
|
+
const MAX_QUEUED = 16 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export async function createP2pProbe({ signal, report = () => {}, onData = () => {}, iceServers = [] }) {
|
|
11
|
+
if (!validProbeIceServers(iceServers)) throw new Error('Invalid probe STUN configuration');
|
|
12
|
+
const { default: rtc } = await import('node-datachannel');
|
|
13
|
+
const peer = new rtc.PeerConnection('code-relax-probe', {
|
|
14
|
+
iceServers,
|
|
15
|
+
maxMessageSize: 65536,
|
|
16
|
+
portRangeBegin: PROBE_UDP_PORT_RANGE.begin,
|
|
17
|
+
portRangeEnd: PROBE_UDP_PORT_RANGE.end
|
|
18
|
+
});
|
|
19
|
+
const diagnostic = new P2pDiagnostics();
|
|
20
|
+
const framer = new P2pDataFramer();
|
|
21
|
+
const dataPrefix = randomUUID().replaceAll('-', '');
|
|
22
|
+
let channel, closed = false, answered = false, candidates = [], verified = false, remoteDirect = false;
|
|
23
|
+
let pingReceived = false, pongReceived = false, dataSerial = 0, queuedBytes = 0;
|
|
24
|
+
const outbound = [];
|
|
25
|
+
const nonce = randomUUID();
|
|
26
|
+
const timer = setTimeout(() => finish(verified ? 'closed' : 'timeout'), 15000);
|
|
27
|
+
function finish(state) {
|
|
28
|
+
if (closed) return;
|
|
29
|
+
closed = true; clearTimeout(timer); candidates = []; queuedBytes = 0; outbound.length = 0; framer.reset();
|
|
30
|
+
const summary = diagnostic.snapshot(state);
|
|
31
|
+
channel?.close(); peer.close(); report(state, summary);
|
|
32
|
+
}
|
|
33
|
+
function verify() {
|
|
34
|
+
if (verified || !pingReceived || !pongReceived) return;
|
|
35
|
+
verified = true; clearTimeout(timer);
|
|
36
|
+
report(remoteDirect ? 'direct' : 'verified', diagnostic.snapshot('verified'));
|
|
37
|
+
pump();
|
|
38
|
+
}
|
|
39
|
+
function pump() {
|
|
40
|
+
if (!verified || !remoteDirect || closed || !channel?.isOpen()) return;
|
|
41
|
+
try {
|
|
42
|
+
while (outbound.length && channel.bufferedAmount() < HIGH_WATER) {
|
|
43
|
+
const item = outbound[0], frame = framer.frame(item);
|
|
44
|
+
if (!channel.sendMessage(frame)) return;
|
|
45
|
+
if (framer.commit(item)) { outbound.shift(); queuedBytes -= item.bytes; }
|
|
46
|
+
}
|
|
47
|
+
} catch { diagnostic.error = 'channel'; finish('failed'); }
|
|
48
|
+
}
|
|
49
|
+
function sendData(payload) {
|
|
50
|
+
if (!verified || !remoteDirect || closed || !channel?.isOpen()) return false;
|
|
51
|
+
let item;
|
|
52
|
+
try { item = framer.create(payload, `${dataPrefix}-${++dataSerial}`); }
|
|
53
|
+
catch { return false; }
|
|
54
|
+
if (queuedBytes + item.bytes > MAX_QUEUED) return false;
|
|
55
|
+
outbound.push(item); queuedBytes += item.bytes; pump(); return true;
|
|
56
|
+
}
|
|
57
|
+
peer.onIceStateChange(state => { if (!closed) diagnostic.ice = state; });
|
|
58
|
+
peer.onGatheringStateChange(state => { if (!closed) diagnostic.gathering = state; });
|
|
59
|
+
peer.onStateChange(state => { if (!closed && (state === 'failed' || state === 'closed')) { diagnostic.error = 'peer'; finish('failed'); } });
|
|
60
|
+
peer.onLocalDescription((sdp, type) => { if (!closed) { diagnostic.offer = type === 'offer'; signal({ kind: type, sdp, iceServers, direct: 1 }); } });
|
|
61
|
+
peer.onLocalCandidate((candidate, mid) => { if (!closed) { diagnostic.candidate('local', candidate); signal({ kind: 'candidate', candidate, mid }); } });
|
|
62
|
+
channel = peer.createDataChannel('code-relax-probe-v1');
|
|
63
|
+
channel.setBufferedAmountLowThreshold(LOW_WATER);
|
|
64
|
+
channel.onBufferedAmountLow(pump);
|
|
65
|
+
channel.onOpen(() => { if (!closed) { diagnostic.open = true; channel.sendMessage(JSON.stringify({ type: 'ping', nonce })); } });
|
|
66
|
+
channel.onError(() => { if (!closed) diagnostic.error = 'channel'; finish('failed'); });
|
|
67
|
+
channel.onClosed(() => finish('closed'));
|
|
68
|
+
channel.onMessage(value => {
|
|
69
|
+
try {
|
|
70
|
+
if (closed || typeof value !== 'string' || value.length > 65536) throw new Error('Invalid P2P frame');
|
|
71
|
+
const message = JSON.parse(value);
|
|
72
|
+
const data = framer.accept(message);
|
|
73
|
+
if (data.handled) {
|
|
74
|
+
if (!verified || !remoteDirect) throw new Error('P2P transport not verified');
|
|
75
|
+
if (data.complete) onData(data.payload);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (message.type === 'ping' && /^[\w-]{16,128}$/.test(message.nonce)) {
|
|
79
|
+
diagnostic.ping = true; pingReceived = true;
|
|
80
|
+
channel.sendMessage(JSON.stringify({ type: 'pong', nonce: message.nonce }));
|
|
81
|
+
} else if (message.type === 'pong' && message.nonce === nonce) {
|
|
82
|
+
diagnostic.pong = true; pongReceived = true;
|
|
83
|
+
} else throw new Error('Invalid P2P response');
|
|
84
|
+
verify();
|
|
85
|
+
} catch { diagnostic.error = 'frame'; finish('failed'); }
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
receive(message) {
|
|
89
|
+
if (closed) return;
|
|
90
|
+
if (message.kind === 'answer' && !answered) {
|
|
91
|
+
try { peer.setRemoteDescription(message.sdp, 'answer'); }
|
|
92
|
+
catch { diagnostic.error = 'description'; finish('failed'); return; }
|
|
93
|
+
answered = true; remoteDirect = message.direct === 1; diagnostic.answer = true;
|
|
94
|
+
for (const item of candidates) peer.addRemoteCandidate(item.candidate, item.mid);
|
|
95
|
+
candidates = [];
|
|
96
|
+
} else if (message.kind === 'candidate') {
|
|
97
|
+
diagnostic.candidate('remote', message.candidate);
|
|
98
|
+
if (answered) peer.addRemoteCandidate(message.candidate, message.mid);
|
|
99
|
+
else if (candidates.length < 64) candidates.push(message);
|
|
100
|
+
else finish('failed');
|
|
101
|
+
} else if (message.kind === 'close') finish('closed');
|
|
102
|
+
else finish('failed');
|
|
103
|
+
},
|
|
104
|
+
send: sendData,
|
|
105
|
+
close() { finish('closed'); }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validProbeSignal(value) {
|
|
110
|
+
if (!value || value.version !== 1 || !/^[\w-]{16,128}$/.test(value.probeId || '')) return false;
|
|
111
|
+
if (['start', 'close'].includes(value.kind)) return true;
|
|
112
|
+
if (['offer', 'answer'].includes(value.kind)) return typeof value.sdp === 'string' && value.sdp.length <= 65536 &&
|
|
113
|
+
(value.direct === undefined || value.direct === 0 || value.direct === 1) &&
|
|
114
|
+
(value.iceServers === undefined || validProbeIceServers(value.iceServers));
|
|
115
|
+
if (value.kind === 'candidate') return typeof value.candidate === 'string' && value.candidate.length <= 2048 &&
|
|
116
|
+
typeof value.mid === 'string' && value.mid.length <= 64;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function validProbeIceServers(value) {
|
|
121
|
+
return Array.isArray(value) && value.length <= 4 && value.every(item =>
|
|
122
|
+
typeof item === 'string' && /^stun:[a-zA-Z0-9.-]+:[0-9]{1,5}$/.test(item) && Number(item.split(':').at(-1)) <= 65535);
|
|
123
|
+
}
|