@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.3
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 +5 -5
- package/dist/bin/self-relay-server.mjs +5 -2
- package/dist/shared/app-server-events.cjs +45 -19
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/src/app-server-tasks.mjs +28 -25
- package/dist/src/lan-socket-server.mjs +8 -2
- package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
- package/dist/src/platform/windows/background-process.mjs +7 -2
- package/dist/src/platform/windows/launch-worker.mjs +42 -0
- package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
- package/dist/src/self-relay/admin-state.mjs +61 -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 +2 -2
- package/dist/src/self-relay/lifecycle.mjs +1 -1
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +137 -11
- package/dist/src/server.mjs +337 -255
- package/dist/src/shared-app-server.mjs +180 -24
- package/dist/src/shared-catalog.mjs +4 -14
- package/dist/src/thread-catalog.mjs +12 -7
- package/dist/web/activity-view.js +283 -0
- package/dist/web/capabilities.js +2 -2
- package/dist/web/chat-transport.js +15 -9
- package/dist/web/chat.css +139 -93
- package/dist/web/chat.js +1451 -2770
- package/dist/web/community-view.js +20 -0
- package/dist/web/community.css +77 -0
- package/dist/web/community.html +37 -0
- package/dist/web/composer-controller.js +101 -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 +141 -38
- 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 +296 -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 +81 -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 +1 -1
- package/tools/postinstall.mjs +111 -2
- package/dist/src/platform/windows/launch-worker.ps1 +0 -13
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import WebSocket from 'ws';
|
|
4
|
-
import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
|
|
4
|
+
import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
|
|
5
5
|
import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
|
|
6
6
|
import { createWindowsRouteHelper } from './windows-route-helper.mjs';
|
|
7
7
|
|
|
@@ -59,7 +59,8 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
59
59
|
if (message.type === 'attach') {
|
|
60
60
|
events.emit('attached', { session: message.session, clientId: message.clientId });
|
|
61
61
|
dropLocal(message.session);
|
|
62
|
-
const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1
|
|
62
|
+
const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1,
|
|
63
|
+
bridgeOutbound: Promise.resolve() };
|
|
63
64
|
locals.set(message.session, current);
|
|
64
65
|
let connection;
|
|
65
66
|
try {
|
|
@@ -69,7 +70,13 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
69
70
|
send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); events.emit('detached', { session: message.session });
|
|
70
71
|
events.emit('diagnostic', 'Local Bridge connection not ready'); return;
|
|
71
72
|
}
|
|
72
|
-
const socket = current.socket = new WebSocket(connection.url, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: false });
|
|
73
|
+
const socket = current.socket = new WebSocket(connection.url, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: false });
|
|
74
|
+
current.forwardToBridge = payload => {
|
|
75
|
+
if (locals.get(message.session) !== current || !current.ready) throw new Error('Local Bridge connection not ready');
|
|
76
|
+
const request = decode(payload, false);
|
|
77
|
+
if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
78
|
+
send(socket, request);
|
|
79
|
+
};
|
|
73
80
|
current.timer = setTimeout(() => socket.terminate(), 5000);
|
|
74
81
|
socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
|
|
75
82
|
socket.on('error', error => events.emit('diagnostic', `Local Bridge: ${error.message}`));
|
|
@@ -84,7 +91,15 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
84
91
|
if (!current.ready) {
|
|
85
92
|
if (response.type !== 'ready') throw new Error('Local Bridge authentication failed');
|
|
86
93
|
clearTimeout(current.timer); current.ready = true; send(remote, { type: 'ready', session: message.session });
|
|
87
|
-
} else
|
|
94
|
+
} else {
|
|
95
|
+
const payload = data.toString();
|
|
96
|
+
current.bridgeOutbound = current.bridgeOutbound.then(async () => {
|
|
97
|
+
const probe = await current.probe;
|
|
98
|
+
if (locals.get(message.session) !== current || !current.ready) return;
|
|
99
|
+
if (!probe?.send?.(payload)) send(remote, { type: 'data', session: message.session, payload });
|
|
100
|
+
});
|
|
101
|
+
void current.bridgeOutbound.catch(error => { events.emit('diagnostic', error.message); socket.terminate(); });
|
|
102
|
+
}
|
|
88
103
|
} catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
|
|
89
104
|
});
|
|
90
105
|
} else if (message.type === 'p2p-probe' && locals.has(message.session)) {
|
|
@@ -101,7 +116,9 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
101
116
|
signal: signal => {
|
|
102
117
|
if (locals.get(message.session) === current && current.probeId === probeId) send(remote, { type: 'p2p-probe', session: message.session,
|
|
103
118
|
version: 1, probeId, ...signal });
|
|
104
|
-
},
|
|
119
|
+
},
|
|
120
|
+
onData: payload => current.forwardToBridge(payload),
|
|
121
|
+
report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
|
|
105
122
|
})).catch(() => {
|
|
106
123
|
events.emit('diagnostic', p2pWindowsRouteHelper ? 'Windows RTC 路由辅助不可用' : 'WebRTC probe initialization failed');
|
|
107
124
|
return { receive() {}, close() {} };
|
|
@@ -121,9 +138,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
121
138
|
else if (message.type === 'data' && locals.has(message.session)) {
|
|
122
139
|
const current = locals.get(message.session);
|
|
123
140
|
if (!current.ready) { send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); return; }
|
|
124
|
-
|
|
125
|
-
if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
126
|
-
send(current.socket, request);
|
|
141
|
+
current.forwardToBridge(message.payload);
|
|
127
142
|
}
|
|
128
143
|
} catch (error) { events.emit('diagnostic', error.message); remote.close(1008, 'Invalid relay message'); }
|
|
129
144
|
});
|
|
@@ -6,7 +6,7 @@ import { connectBridgeRelay } from './connector.mjs';
|
|
|
6
6
|
import { connectRelayClient } from './client.mjs';
|
|
7
7
|
import { secret } from './wire.mjs';
|
|
8
8
|
|
|
9
|
-
export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispatch, remoteRelay, hostName = 'TEST-PC', lanCandidates = [] } = {}) {
|
|
9
|
+
export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispatch, remoteRelay, hostName = 'TEST-PC', lanCandidates = [], relayOptions = {} } = {}) {
|
|
10
10
|
if (!['lan-legacy', 'lan-shared'].includes(mode)) throw new Error('Invalid demo mode');
|
|
11
11
|
const token = secret(), calls = [];
|
|
12
12
|
const bridge = http.createServer((_request, response) => { response.writeHead(404); response.end(); });
|
|
@@ -22,7 +22,7 @@ export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispat
|
|
|
22
22
|
reply.json(404, { error: '模拟 Bridge 不执行真实任务' });
|
|
23
23
|
} });
|
|
24
24
|
await new Promise(resolve => bridge.listen(0, '127.0.0.1', resolve));
|
|
25
|
-
const relay = remoteRelay ? { ...remoteRelay, async close() {} } : await startSelfRelay({ pairingTtlMs });
|
|
25
|
+
const relay = remoteRelay ? { ...remoteRelay, async close() {} } : await startSelfRelay({ pairingTtlMs, ...relayOptions });
|
|
26
26
|
const bridgeUrl = `ws://127.0.0.1:${bridge.address().port}/api/socket`;
|
|
27
27
|
const identity = remoteRelay ? { id: randomUUID(), secret: secret() } : undefined;
|
|
28
28
|
const clientId = randomUUID(), credential = secret();
|
|
@@ -219,7 +219,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
219
219
|
if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
|
|
220
220
|
});
|
|
221
221
|
const registration = await current.ready;
|
|
222
|
-
if (registration.protocolVersion !==
|
|
222
|
+
if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
|
|
223
223
|
current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
|
|
224
224
|
}
|
|
225
225
|
if (!identity.registered) {
|
|
@@ -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 === 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
|
+
}
|
|
@@ -1,17 +1,19 @@
|
|
|
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
|
+
|
|
9
10
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
10
11
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
11
12
|
maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
12
|
-
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
13
|
+
maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
13
14
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
14
15
|
if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
|
|
16
|
+
if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
|
|
15
17
|
if (publicRelayUrl) relayPublicAddress(publicRelayUrl);
|
|
16
18
|
// Harmony's native WebSocket supplies the endpoint Origin automatically.
|
|
17
19
|
// libwebsockets can use http even for WSS; trust only the configured endpoint.
|
|
@@ -20,6 +22,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
20
22
|
const hostHash = digest(hostKey), rooms = new Map();
|
|
21
23
|
const store = deviceStore(stateFile);
|
|
22
24
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
25
|
+
const adminState = relayAdminState(adminStateFile, maxRelaySessions);
|
|
26
|
+
let activeRelaySessions = 0;
|
|
23
27
|
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
|
|
24
28
|
function commit(room, changes) {
|
|
25
29
|
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
@@ -46,6 +50,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
46
50
|
}
|
|
47
51
|
const server = http.createServer((request, response) => {
|
|
48
52
|
const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
53
|
+
if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
|
|
54
|
+
void handleAdmin(request, response, target); return;
|
|
55
|
+
}
|
|
56
|
+
if (request.method === 'GET' && target.pathname === '/announcement') {
|
|
57
|
+
const body = JSON.stringify({ announcement: adminState.read().announcement });
|
|
58
|
+
response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
|
|
59
|
+
}
|
|
49
60
|
if (request.method !== 'GET' || !['/pair', '/pair/resolve'].includes(target.pathname)) {
|
|
50
61
|
response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
|
|
51
62
|
}
|
|
@@ -57,7 +68,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
57
68
|
response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
|
|
58
69
|
}
|
|
59
70
|
if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
|
|
60
|
-
const body = JSON.stringify({ version:
|
|
71
|
+
const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
|
|
61
72
|
response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
|
|
62
73
|
}
|
|
63
74
|
const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
|
|
@@ -65,6 +76,63 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
65
76
|
const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
|
|
66
77
|
response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
|
|
67
78
|
});
|
|
79
|
+
function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
80
|
+
const value = typeof body === 'string' ? body : JSON.stringify(body);
|
|
81
|
+
response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store',
|
|
82
|
+
'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
|
+
response.end(value);
|
|
84
|
+
}
|
|
85
|
+
async function handleAdmin(request, response, target) {
|
|
86
|
+
if (request.method === 'GET' && target.pathname === '/admin') {
|
|
87
|
+
adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
|
|
88
|
+
}
|
|
89
|
+
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
90
|
+
const state = adminState.read();
|
|
91
|
+
adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
|
|
92
|
+
activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
|
|
93
|
+
websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
|
|
94
|
+
activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0), announcement: state.announcement }); return;
|
|
95
|
+
}
|
|
96
|
+
if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
|
|
97
|
+
request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
|
|
98
|
+
try {
|
|
99
|
+
const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
|
|
100
|
+
if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
|
|
101
|
+
const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
|
|
102
|
+
log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
|
|
103
|
+
} else if (target.pathname === '/admin/api/announcement' && request.method === 'PUT') {
|
|
104
|
+
const state = adminState.publish(body.title, body.message); broadcastAnnouncement(state.announcement);
|
|
105
|
+
log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
|
|
106
|
+
} else if (target.pathname === '/admin/api/announcement' && request.method === 'DELETE') {
|
|
107
|
+
const state = adminState.withdraw(); broadcastAnnouncement(state.announcement);
|
|
108
|
+
log({ event: 'admin-announcement-withdrawn' });
|
|
109
|
+
} else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
|
|
110
|
+
adminReply(response, 200, { ok: true });
|
|
111
|
+
} catch (error) { adminReply(response, 400, { error: error.message }); }
|
|
112
|
+
}
|
|
113
|
+
function broadcastAnnouncement(announcement) {
|
|
114
|
+
for (const room of rooms.values()) {
|
|
115
|
+
for (const observer of room.observers.values()) send(observer.ws, { type: 'announcement', announcement });
|
|
116
|
+
for (const connection of room.connections.values()) send(connection.ws, { type: 'announcement', announcement });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function releaseRelay(connection) {
|
|
120
|
+
if (!connection?.relayAdmitted) return;
|
|
121
|
+
connection.relayAdmitted = false; activeRelaySessions = Math.max(0, activeRelaySessions - 1);
|
|
122
|
+
}
|
|
123
|
+
function admitRelay(connection, room) {
|
|
124
|
+
if (connection.relayAdmitted || connection.relayExempt) return true;
|
|
125
|
+
if (connection.capacityExempt) {
|
|
126
|
+
connection.relayExempt = true;
|
|
127
|
+
log({ event: 'relay-capacity-exempt', device: room.id }); return true;
|
|
128
|
+
}
|
|
129
|
+
const limit = adminState.read().maxRelaySessions;
|
|
130
|
+
if (activeRelaySessions >= limit) {
|
|
131
|
+
log({ event: 'relay-capacity-rejected', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return false;
|
|
132
|
+
}
|
|
133
|
+
connection.relayAdmitted = true; activeRelaySessions++;
|
|
134
|
+
log({ event: 'relay-capacity-admitted', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return true;
|
|
135
|
+
}
|
|
68
136
|
const wss = new WebSocketServer({ noServer: true, maxPayload, perMessageDeflate: {
|
|
69
137
|
serverNoContextTakeover: true, clientNoContextTakeover: true,
|
|
70
138
|
threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
|
|
@@ -81,12 +149,24 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
81
149
|
}
|
|
82
150
|
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
|
|
83
151
|
});
|
|
84
|
-
wss.on('connection', ws => {
|
|
152
|
+
wss.on('connection', ws => {
|
|
85
153
|
let role, room, session, clientId, alive = true;
|
|
86
154
|
const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
|
|
87
155
|
ws.on('pong', () => { alive = true; });
|
|
88
156
|
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);
|
|
157
|
+
const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
|
|
158
|
+
const queueClientMessage = (connection, message, pause = 0) => {
|
|
159
|
+
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
160
|
+
connection.queuedBytes = (connection.queuedBytes || 0) + bytes;
|
|
161
|
+
if (connection.queuedBytes > 40 * 1024 * 1024) {
|
|
162
|
+
connection.ws.close(1013, 'Receiver too slow'); return;
|
|
163
|
+
}
|
|
164
|
+
connection.outbound = (connection.outbound || Promise.resolve()).then(async () => {
|
|
165
|
+
if (pause > 0) await new Promise(resolve => setTimeout(resolve, pause));
|
|
166
|
+
connection.queuedBytes -= bytes;
|
|
167
|
+
send(connection.ws, message);
|
|
168
|
+
}).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
|
|
169
|
+
};
|
|
90
170
|
ws.on('error', () => {}); // close owns routing cleanup.
|
|
91
171
|
ws.on('close', (code, reason) => {
|
|
92
172
|
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
@@ -97,6 +177,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
97
177
|
if (!room.durable) rooms.delete(room.id);
|
|
98
178
|
for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
|
|
99
179
|
} else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
180
|
+
releaseRelay(room.connections.get(clientId));
|
|
100
181
|
room.connections.delete(clientId);
|
|
101
182
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
102
183
|
} else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
|
|
@@ -143,7 +224,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
143
224
|
send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
144
225
|
}
|
|
145
226
|
const invite = room.clients.length ? {} : invitation(room);
|
|
146
|
-
send(ws, { type: 'registered', protocolVersion:
|
|
227
|
+
send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
|
|
147
228
|
clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
|
|
148
229
|
}
|
|
149
230
|
room = rooms.get(message.room);
|
|
@@ -177,6 +258,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
177
258
|
room.observers.set(clientId, { ws });
|
|
178
259
|
send(ws, { type: 'observing', online: Boolean(room.host) });
|
|
179
260
|
send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
261
|
+
const announcement = adminState.read().announcement;
|
|
262
|
+
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
180
263
|
if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
181
264
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
182
265
|
if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
|
|
@@ -186,14 +269,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
186
269
|
const oldConnection = room.connections.get(clientId);
|
|
187
270
|
role = 'client'; clearTimeout(authTimer); session = randomUUID();
|
|
188
271
|
if (oldConnection?.ws && oldConnection.ws !== ws) {
|
|
272
|
+
releaseRelay(oldConnection);
|
|
189
273
|
send(room.host, { type: 'detach', session: oldConnection.session });
|
|
190
274
|
oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
|
|
191
275
|
}
|
|
192
276
|
room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
|
|
193
|
-
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0
|
|
194
|
-
|
|
277
|
+
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
278
|
+
relayAdmitted: false, relayExempt: false,
|
|
279
|
+
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
280
|
+
send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
|
|
195
281
|
send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
196
282
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
283
|
+
const announcement = adminState.read().announcement;
|
|
284
|
+
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
197
285
|
send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
|
|
198
286
|
}
|
|
199
287
|
if (role === 'host') {
|
|
@@ -214,6 +302,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
214
302
|
commit(room, { clients: [], revision: room.revision + 1 });
|
|
215
303
|
room.pairingHash = undefined;
|
|
216
304
|
for (const connection of room.connections.values()) {
|
|
305
|
+
releaseRelay(connection);
|
|
217
306
|
send(ws, { type: 'detach', session: connection.session });
|
|
218
307
|
connection.ws.close(4401, 'Device authorization revoked');
|
|
219
308
|
}
|
|
@@ -225,8 +314,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
225
314
|
}
|
|
226
315
|
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
227
316
|
if (!connection) return; // Late frames from an ended session.
|
|
228
|
-
if (message.type === 'data' && typeof message.payload === 'string')
|
|
229
|
-
else if (message.type === 'ready')
|
|
317
|
+
if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
|
|
318
|
+
else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
|
|
230
319
|
p2pProbeRepeat: connection.p2pProbe && connection.repeat });
|
|
231
320
|
else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
|
|
232
321
|
if (connection.probeId !== message.probeId) return;
|
|
@@ -251,7 +340,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
251
340
|
if (connection.probeId !== message.probeId) return;
|
|
252
341
|
if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
|
|
253
342
|
send(room.host, { ...message, session });
|
|
343
|
+
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
|
|
344
|
+
releaseRelay(room.connections.get(clientId));
|
|
254
345
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|
|
346
|
+
const connection = room.connections.get(clientId);
|
|
347
|
+
if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
|
|
348
|
+
ws.close(4428, 'Server busy'); return;
|
|
349
|
+
}
|
|
255
350
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
256
351
|
} else fail();
|
|
257
352
|
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
@@ -338,3 +433,34 @@ function plainLabel(value) {
|
|
|
338
433
|
function plainText(value, limit, empty) {
|
|
339
434
|
return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
|
|
340
435
|
}
|
|
436
|
+
|
|
437
|
+
function controlPayload(value) {
|
|
438
|
+
try {
|
|
439
|
+
const message = JSON.parse(value);
|
|
440
|
+
if (message.type === 'unsubscribe') return true;
|
|
441
|
+
return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
|
|
442
|
+
(message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
|
|
443
|
+
} catch { return false; }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function readBody(request, limit) {
|
|
447
|
+
return new Promise((resolve, reject) => {
|
|
448
|
+
let value = '';
|
|
449
|
+
request.setEncoding('utf8');
|
|
450
|
+
request.on('data', chunk => {
|
|
451
|
+
value += chunk;
|
|
452
|
+
if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
|
|
453
|
+
});
|
|
454
|
+
request.on('end', () => resolve(value)); request.on('error', reject);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
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
|
+
}
|