@walkhi/code-relax 0.1.0-beta.21 → 0.1.0-beta.23
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/dist/bin/migrate-relay-db.mjs +4 -2
- package/dist/bin/self-relay-server.mjs +20 -19
- package/dist/shared/app-server-events.cjs +107 -100
- package/dist/shared/service-endpoints.cjs +21 -0
- package/dist/shared/timeline-sync.cjs +94 -5
- package/dist/skills/code-relax/SKILL.md +21 -18
- package/dist/src/lan-socket-server.mjs +50 -10
- package/dist/src/self-relay/admin-page.mjs +18 -11
- package/dist/src/self-relay/admin-server.mjs +1 -2
- package/dist/src/self-relay/connector.mjs +29 -4
- package/dist/src/self-relay/diagnostics.mjs +54 -0
- package/dist/src/self-relay/lifecycle.mjs +160 -160
- package/dist/src/self-relay/mysql-state.mjs +2 -1
- package/dist/src/self-relay/server.mjs +22 -23
- package/dist/src/self-relay/usage-daily.mjs +55 -0
- package/dist/src/self-relay/usage-meter.mjs +42 -31
- package/dist/src/server.mjs +100 -203
- package/dist/src/shared-app-server.mjs +132 -116
- package/dist/src/shared-thread-stream.mjs +56 -50
- package/dist/src/turn-model-history.mjs +47 -0
- package/package.json +1 -1
- package/dist/src/self-relay/admin-usage-page.mjs +0 -28
- package/dist/src/self-relay/usage-diagnostics.mjs +0 -46
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { serviceHealth } from '../service-health.mjs';
|
|
2
|
+
import serviceEndpoints from '../../shared/service-endpoints.cjs';
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import QRCode from 'qrcode';
|
|
6
|
+
import { credentialStore } from '../platform/windows/credential-store.mjs';
|
|
7
|
+
import { readRecord, writeRecord } from '../service-lifecycle.mjs';
|
|
8
|
+
import { serviceHealth } from '../service-health.mjs';
|
|
11
9
|
import { controlRequest } from '../service-control.mjs';
|
|
12
|
-
import { connectBridgeRelay } from './connector.mjs';
|
|
13
|
-
import { validProbeIceServers } from './p2p-probe.mjs';
|
|
14
|
-
import { relayAddress, validSecret } from './wire.mjs';
|
|
15
|
-
import { saveLanAuthorizations } from './lan-authorizations.mjs';
|
|
10
|
+
import { connectBridgeRelay } from './connector.mjs';
|
|
11
|
+
import { validProbeIceServers } from './p2p-probe.mjs';
|
|
12
|
+
import { relayAddress, validSecret } from './wire.mjs';
|
|
13
|
+
import { saveLanAuthorizations } from './lan-authorizations.mjs';
|
|
16
14
|
import { lanNetworks } from '../platform/windows/network-info.mjs';
|
|
17
15
|
|
|
18
|
-
const
|
|
19
|
-
const officialConfig = { url: 'wss://api.uulife.site/relay' };
|
|
16
|
+
const officialConfig = { url: serviceEndpoints.relayUrl };
|
|
20
17
|
|
|
21
18
|
async function loadConfig(stateRoot) {
|
|
22
19
|
const configPath = path.join(stateRoot, 'self-relay-config.json');
|
|
@@ -26,33 +23,16 @@ async function loadConfig(stateRoot) {
|
|
|
26
23
|
if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
|
|
27
24
|
if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
|
|
28
25
|
if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
|
|
29
|
-
const url = process.env.RELAY_URL || config.url;
|
|
30
|
-
if (!url) throw new Error('请先配置 self-relay-config.json 的 url
|
|
31
|
-
relayAddress(url);
|
|
32
|
-
const resolved = relayAddress(url);
|
|
33
|
-
const pairingUrl = config.pairingUrl || `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair
|
|
34
|
-
const pairing = new URL(pairingUrl);
|
|
35
|
-
if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
|
|
36
|
-
(pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
|
|
26
|
+
const url = process.env.RELAY_URL || serviceEndpoints.resolveRelayUrl(config.url);
|
|
27
|
+
if (!url) throw new Error('请先配置 self-relay-config.json 的 url,详见自建中继文档。');
|
|
28
|
+
relayAddress(url);
|
|
29
|
+
const resolved = relayAddress(url);
|
|
30
|
+
const pairingUrl = config.pairingUrl || (resolved === serviceEndpoints.relayUrl ? serviceEndpoints.pairingUrl : `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`);
|
|
31
|
+
const pairing = new URL(pairingUrl);
|
|
32
|
+
if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
|
|
33
|
+
(pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
|
|
37
34
|
return { ...config, url: resolved, pairingUrl: pairing.href };
|
|
38
35
|
}
|
|
39
|
-
async function registrationKey(config) {
|
|
40
|
-
if (config.url === officialConfig.url) return undefined;
|
|
41
|
-
const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
|
|
42
|
-
let hostKey;
|
|
43
|
-
if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
|
|
44
|
-
else {
|
|
45
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
|
|
46
|
-
!/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
|
|
47
|
-
try {
|
|
48
|
-
const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
|
|
49
|
-
`sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
|
|
50
|
-
hostKey = result.stdout.trim();
|
|
51
|
-
} catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
|
|
52
|
-
}
|
|
53
|
-
if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
|
|
54
|
-
return hostKey;
|
|
55
|
-
}
|
|
56
36
|
export async function selfRelay(command, stateRoot) {
|
|
57
37
|
const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
58
38
|
if (!session) {
|
|
@@ -64,142 +44,162 @@ export async function selfRelay(command, stateRoot) {
|
|
|
64
44
|
}
|
|
65
45
|
|
|
66
46
|
async function createRelayRuntime(stateRoot) {
|
|
67
|
-
const config = await loadConfig(stateRoot);
|
|
68
|
-
const
|
|
69
|
-
|
|
47
|
+
const config = await loadConfig(stateRoot);
|
|
48
|
+
const configPath = path.join(stateRoot, 'self-relay-config.json');
|
|
49
|
+
const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
50
|
+
let health = await serviceHealth(session());
|
|
70
51
|
if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
|
|
71
52
|
const stored = await credentialStore('load');
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
53
|
+
let identities = stored.record || { version: 2, devices: {} };
|
|
54
|
+
const validIdentity = (id, item) => /^[\w-]{16,128}$/.test(id) && item?.id === id &&
|
|
55
|
+
validSecret(item.secret) && typeof item.registered === 'boolean';
|
|
56
|
+
if (identities.version === 1 && identities.servers && typeof identities.servers === 'object' && !Array.isArray(identities.servers)) {
|
|
57
|
+
const devices = {};
|
|
58
|
+
for (const [url, item] of Object.entries(identities.servers)) {
|
|
59
|
+
relayAddress(url);
|
|
60
|
+
if (!validIdentity(item?.id, item) || devices[item.id]) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
61
|
+
devices[item.id] = item;
|
|
62
|
+
}
|
|
63
|
+
const previous = identities.servers[config.url];
|
|
64
|
+
identities = { version: 2, devices };
|
|
65
|
+
await credentialStore('save', identities);
|
|
66
|
+
if (!config.deviceId && previous) config.deviceId = previous.id;
|
|
67
|
+
}
|
|
68
|
+
if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
|
|
69
|
+
Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
70
|
+
if (!config.deviceId) {
|
|
71
|
+
const known = Object.values(identities.devices);
|
|
72
|
+
if (known.length > 1) throw new Error('存在多个工作站身份,请在 self-relay-config.json 中指定 deviceId。');
|
|
73
|
+
config.deviceId = known[0]?.id || randomUUID();
|
|
74
|
+
}
|
|
75
|
+
const createIdentity = config.deviceId === 'new';
|
|
76
|
+
if (createIdentity) config.deviceId = randomUUID();
|
|
77
|
+
if (!/^[\w-]{16,128}$/.test(config.deviceId)) throw new Error('self-relay-config.json 的 deviceId 无效。');
|
|
78
|
+
let identity = identities.devices[config.deviceId];
|
|
82
79
|
if (!identity) {
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
if (!createIdentity && Object.keys(identities.devices).length) throw new Error('找不到配置指定的工作站身份,未自动创建新身份。');
|
|
81
|
+
identity = { id: config.deviceId, secret: randomBytes(32).toString('base64url'), registered: false };
|
|
82
|
+
identities.devices[identity.id] = identity;
|
|
83
|
+
await credentialStore('save', identities);
|
|
85
84
|
}
|
|
86
|
-
const
|
|
85
|
+
const persistedConfig = readRecord(configPath) || {};
|
|
86
|
+
if (persistedConfig.deviceId !== identity.id) writeRecord(configPath, { ...persistedConfig, deviceId: identity.id });
|
|
87
87
|
let bridgeError = '';
|
|
88
|
-
async function refreshHealth() {
|
|
88
|
+
async function refreshHealth() {
|
|
89
89
|
try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
|
|
90
90
|
catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
|
|
91
|
-
}
|
|
91
|
+
}
|
|
92
92
|
let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
|
|
93
|
-
let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
|
|
94
|
-
const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
|
|
95
|
-
async function currentLanCandidates() {
|
|
96
|
-
try {
|
|
97
|
-
const bridge = session(), port = bridge?.port || 45831;
|
|
98
|
-
return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
|
|
99
|
-
} catch (error) {
|
|
100
|
-
audit('lan-candidates-unavailable', { message: error.message }); return [];
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const status = () => {
|
|
104
|
-
const available = online && !paired && qrPath && Date.now() < expiresAt;
|
|
105
|
-
const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
|
|
106
|
-
: paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
|
|
107
|
-
: available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
|
|
93
|
+
let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
|
|
94
|
+
const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
|
|
95
|
+
async function currentLanCandidates() {
|
|
96
|
+
try {
|
|
97
|
+
const bridge = session(), port = bridge?.port || 45831;
|
|
98
|
+
return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
audit('lan-candidates-unavailable', { message: error.message }); return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const status = () => {
|
|
104
|
+
const available = online && !paired && qrPath && Date.now() < expiresAt;
|
|
105
|
+
const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
|
|
106
|
+
: paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
|
|
107
|
+
: available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
|
|
108
108
|
: '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
|
|
109
|
-
return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
|
|
110
|
-
relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
|
|
109
|
+
return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
|
|
110
|
+
relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
|
|
111
111
|
bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
|
|
112
|
-
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
113
|
-
...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
|
|
112
|
+
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
113
|
+
...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
|
|
114
114
|
displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}` };
|
|
115
|
-
};
|
|
116
|
-
async function shutdown() {
|
|
117
|
-
if (closing) return;
|
|
118
|
-
closing = true; clearTimeout(retryTimer);
|
|
119
|
-
connector?.events.removeAllListeners(); connector?.close();
|
|
120
|
-
}
|
|
121
|
-
async function saveInvitation(message) {
|
|
122
|
-
if (!message.code) return;
|
|
123
|
-
expiresAt = message.expires;
|
|
124
|
-
const link = new URL(config.pairingUrl);
|
|
125
|
-
link.searchParams.set('invite', message.code);
|
|
126
|
-
// Keep previously displayed files intact even when their invitation expires.
|
|
127
|
-
qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
|
|
128
|
-
await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
|
|
129
|
-
}
|
|
130
|
-
function scheduleRetry() {
|
|
131
|
-
if (closing || retryTimer) return;
|
|
132
|
-
const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
|
|
133
|
-
retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
|
|
134
|
-
}
|
|
135
|
-
async function connect() {
|
|
136
|
-
if (closing || connecting) return;
|
|
137
|
-
connecting = true;
|
|
138
|
-
let retryAfterAttempt = false;
|
|
139
|
-
try {
|
|
140
|
-
const bridge = session();
|
|
141
|
-
await refreshHealth();
|
|
142
|
-
const lanCandidates = await currentLanCandidates();
|
|
143
|
-
connector?.events.removeAllListeners(); connector?.close();
|
|
144
|
-
const current = connector = connectBridgeRelay({ relayUrl: config.url,
|
|
115
|
+
};
|
|
116
|
+
async function shutdown() {
|
|
117
|
+
if (closing) return;
|
|
118
|
+
closing = true; clearTimeout(retryTimer);
|
|
119
|
+
connector?.events.removeAllListeners(); connector?.close();
|
|
120
|
+
}
|
|
121
|
+
async function saveInvitation(message) {
|
|
122
|
+
if (!message.code) return;
|
|
123
|
+
expiresAt = message.expires;
|
|
124
|
+
const link = new URL(config.pairingUrl);
|
|
125
|
+
link.searchParams.set('invite', message.code);
|
|
126
|
+
// Keep previously displayed files intact even when their invitation expires.
|
|
127
|
+
qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
|
|
128
|
+
await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
|
|
129
|
+
}
|
|
130
|
+
function scheduleRetry() {
|
|
131
|
+
if (closing || retryTimer) return;
|
|
132
|
+
const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
|
|
133
|
+
retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
|
|
134
|
+
}
|
|
135
|
+
async function connect() {
|
|
136
|
+
if (closing || connecting) return;
|
|
137
|
+
connecting = true;
|
|
138
|
+
let retryAfterAttempt = false;
|
|
139
|
+
try {
|
|
140
|
+
const bridge = session();
|
|
141
|
+
await refreshHealth();
|
|
142
|
+
const lanCandidates = await currentLanCandidates();
|
|
143
|
+
connector?.events.removeAllListeners(); connector?.close();
|
|
144
|
+
const current = connector = connectBridgeRelay({ relayUrl: config.url, identity,
|
|
145
145
|
usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
|
|
146
|
-
bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
|
|
147
|
-
bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
|
|
146
|
+
bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
|
|
147
|
+
bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
|
|
148
148
|
hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
|
|
149
149
|
p2pWindowsRouteHelper: config.p2pWindowsRouteHelper === true, p2pWindowsRouteHelperToken: config.p2pWindowsRouteHelperToken || '' });
|
|
150
|
-
current.events.on('p2p-probe', result => audit('p2p-probe', result));
|
|
151
|
-
current.events.on('diagnostic', message => audit('transport-error', { message }));
|
|
152
|
-
current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
|
|
153
|
-
current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
|
|
154
|
-
current.events.on('authorization-changed', update => {
|
|
155
|
-
saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
|
|
156
|
-
paired = update.clients.length > 0; clientCount = update.clients.length;
|
|
157
|
-
audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
|
|
158
|
-
});
|
|
159
|
-
current.events.once('disconnected', event => {
|
|
160
|
-
if (connector !== current || closing) return;
|
|
161
|
-
online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
|
|
162
|
-
if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
|
|
163
|
-
});
|
|
164
|
-
const registration = await current.ready;
|
|
150
|
+
current.events.on('p2p-probe', result => audit('p2p-probe', result));
|
|
151
|
+
current.events.on('diagnostic', message => audit('transport-error', { message }));
|
|
152
|
+
current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
|
|
153
|
+
current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
|
|
154
|
+
current.events.on('authorization-changed', update => {
|
|
155
|
+
saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
|
|
156
|
+
paired = update.clients.length > 0; clientCount = update.clients.length;
|
|
157
|
+
audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
|
|
158
|
+
});
|
|
159
|
+
current.events.once('disconnected', event => {
|
|
160
|
+
if (connector !== current || closing) return;
|
|
161
|
+
online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
|
|
162
|
+
if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
|
|
163
|
+
});
|
|
164
|
+
const registration = await current.ready;
|
|
165
165
|
if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
|
|
166
|
-
current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
|
|
167
|
-
}
|
|
168
|
-
if (!identity.registered) {
|
|
169
|
-
const registeredIdentity = { ...identity, registered: true };
|
|
170
|
-
identities.
|
|
171
|
-
await credentialStore('save', identities); identity = registeredIdentity;
|
|
172
|
-
}
|
|
173
|
-
current.assertConnected();
|
|
174
|
-
online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
|
|
175
|
-
saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
|
|
176
|
-
await saveInvitation(registration); audit('relay-connected', { paired });
|
|
177
|
-
} catch (error) {
|
|
178
|
-
online = false; lastError = error.message; audit('connect-failed', { message: error.message });
|
|
179
|
-
if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
|
|
180
|
-
else { clearTimeout(retryTimer); retryTimer = undefined; }
|
|
181
|
-
} finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
|
|
182
|
-
}
|
|
183
|
-
async function pair() {
|
|
184
|
-
if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
|
|
185
|
-
await saveInvitation(await connector.command('pair-code'));
|
|
186
|
-
// Each invitation adds or refreshes one mobile identity without revoking other devices.
|
|
187
|
-
return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
|
|
188
|
-
displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
|
|
189
|
-
}
|
|
190
|
-
async function revoke() {
|
|
191
|
-
if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
|
|
192
|
-
await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
|
|
166
|
+
current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
|
|
167
|
+
}
|
|
168
|
+
if (!identity.registered) {
|
|
169
|
+
const registeredIdentity = { ...identity, registered: true };
|
|
170
|
+
identities.devices[identity.id] = registeredIdentity;
|
|
171
|
+
await credentialStore('save', identities); identity = registeredIdentity;
|
|
172
|
+
}
|
|
173
|
+
current.assertConnected();
|
|
174
|
+
online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
|
|
175
|
+
saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
|
|
176
|
+
await saveInvitation(registration); audit('relay-connected', { paired });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
online = false; lastError = error.message; audit('connect-failed', { message: error.message });
|
|
179
|
+
if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
|
|
180
|
+
else { clearTimeout(retryTimer); retryTimer = undefined; }
|
|
181
|
+
} finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
|
|
182
|
+
}
|
|
183
|
+
async function pair() {
|
|
184
|
+
if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
|
|
185
|
+
await saveInvitation(await connector.command('pair-code'));
|
|
186
|
+
// Each invitation adds or refreshes one mobile identity without revoking other devices.
|
|
187
|
+
return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
|
|
188
|
+
displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
|
|
189
|
+
}
|
|
190
|
+
async function revoke() {
|
|
191
|
+
if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
|
|
192
|
+
await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
|
|
193
193
|
return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
|
|
194
|
-
}
|
|
195
|
-
async function refreshLan() {
|
|
196
|
-
if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
|
|
197
|
-
await refreshHealth();
|
|
198
|
-
const candidates = await currentLanCandidates();
|
|
199
|
-
connector.updateLanCandidates(candidates);
|
|
200
|
-
audit('lan-candidates-updated', { count: candidates.length });
|
|
201
|
-
return { ...status(), lanCandidateCount: candidates.length };
|
|
202
|
-
}
|
|
194
|
+
}
|
|
195
|
+
async function refreshLan() {
|
|
196
|
+
if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
|
|
197
|
+
await refreshHealth();
|
|
198
|
+
const candidates = await currentLanCandidates();
|
|
199
|
+
connector.updateLanCandidates(candidates);
|
|
200
|
+
audit('lan-candidates-updated', { count: candidates.length });
|
|
201
|
+
return { ...status(), lanCandidateCount: candidates.length };
|
|
202
|
+
}
|
|
203
203
|
const inspect = async () => {
|
|
204
204
|
if (online) {
|
|
205
205
|
const remote = await connector.command('status'); paired = remote.paired;
|
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import mysql from 'mysql2/promise';
|
|
5
5
|
import { deviceStore } from './device-store.mjs';
|
|
6
6
|
import { relayAdminState } from './admin-state.mjs';
|
|
7
|
+
import { relaySchemaVersions } from '../../database/versions.mjs';
|
|
7
8
|
|
|
8
9
|
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
9
10
|
const isoTime = value => value.replace(' ', 'T') + 'Z';
|
|
@@ -17,7 +18,7 @@ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
|
|
|
17
18
|
namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
|
|
18
19
|
try {
|
|
19
20
|
const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
|
|
20
|
-
const knownVersions =
|
|
21
|
+
const knownVersions = relaySchemaVersions;
|
|
21
22
|
if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
|
|
22
23
|
for (const row of versions) {
|
|
23
24
|
if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
|
|
@@ -12,7 +12,7 @@ import { openRelayMysql } from './mysql-state.mjs';
|
|
|
12
12
|
import { adminPage } from './admin-page.mjs';
|
|
13
13
|
import { openEmailAccounts } from './email-accounts.mjs';
|
|
14
14
|
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { relayDiagnostics } from './diagnostics.mjs';
|
|
16
16
|
|
|
17
17
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
18
18
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
@@ -32,8 +32,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
32
32
|
if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
|
|
33
33
|
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
34
34
|
const meter = accounts ? usageMeter(database.pool) : null;
|
|
35
|
-
const diagnostics = database?.schemaVersions.includes('
|
|
36
|
-
?
|
|
35
|
+
const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
|
|
36
|
+
? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
|
|
37
37
|
const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
|
|
38
38
|
if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
|
|
39
39
|
const store = database?.devices || deviceStore(stateFile);
|
|
@@ -180,20 +180,22 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
180
180
|
}
|
|
181
181
|
if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
|
|
182
182
|
const page = Number(target.searchParams.get('page') || '1');
|
|
183
|
-
const
|
|
184
|
-
if (!Number.isSafeInteger(page) || page < 1 || page > 100000 ||
|
|
183
|
+
const view = target.searchParams.get('view') || 'user';
|
|
184
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 100000 ||
|
|
185
|
+
!['user', 'computer'].includes(view) || target.searchParams.has('user') ||
|
|
186
|
+
target.searchParams.has('computer')) {
|
|
185
187
|
adminReply(response, 400, { error: 'Invalid usage query' }); return;
|
|
186
188
|
}
|
|
187
|
-
adminReply(response, 200, await usageReport(database.pool, page,
|
|
189
|
+
adminReply(response, 200, await usageReport(database.pool, page, view)); return;
|
|
188
190
|
}
|
|
189
191
|
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
|
+
if (request.method === 'GET') adminReply(response, 200, await diagnostics.read('usage'));
|
|
193
|
+
else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear('usage'));
|
|
192
194
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
193
195
|
return;
|
|
194
196
|
}
|
|
195
197
|
if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
|
|
196
|
-
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain());
|
|
198
|
+
if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain('usage'));
|
|
197
199
|
else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
|
|
198
200
|
return;
|
|
199
201
|
}
|
|
@@ -457,7 +459,6 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
457
459
|
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
458
460
|
relayAdmitted: false, relayExempt: false,
|
|
459
461
|
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
460
|
-
diagnostics?.record('client', userId ? 'account-linked' : 'anonymous');
|
|
461
462
|
send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
|
|
462
463
|
send(ws, { type: 'computer-info', hostName: room.hostName });
|
|
463
464
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
@@ -478,10 +479,9 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
478
479
|
const armKey = `${message.session}:${message.requestId}`;
|
|
479
480
|
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
480
481
|
if (message.type === 'usage-arm') {
|
|
481
|
-
diagnostics?.record('arm', connection?.userId ? 'account-linked' : 'anonymous');
|
|
482
482
|
if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
|
|
483
483
|
(message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
|
|
484
|
-
diagnostics?.record('arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
|
|
484
|
+
diagnostics?.record('usage', 'arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
|
|
485
485
|
return;
|
|
486
486
|
}
|
|
487
487
|
for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
|
|
@@ -489,30 +489,26 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
489
489
|
room.usageArms.set(armKey, { userId: connection.userId,
|
|
490
490
|
clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
|
|
491
491
|
session: message.session, at: Date.now() });
|
|
492
|
-
diagnostics?.record('arm-accepted', 'ready');
|
|
493
492
|
return;
|
|
494
493
|
}
|
|
495
494
|
if (message.type === 'usage-cancel') {
|
|
496
|
-
diagnostics?.record('message-cancelled', room.usageArms.has(armKey) ? 'armed' : 'arm-missing');
|
|
497
495
|
room.usageArms.delete(armKey); return;
|
|
498
496
|
}
|
|
499
497
|
if (message.type === 'usage-message') {
|
|
500
498
|
const arm = room.usageArms.get(armKey);
|
|
501
|
-
diagnostics?.record('message', arm ? 'armed' : 'arm-missing');
|
|
502
499
|
room.usageArms.delete(armKey);
|
|
503
500
|
if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
|
|
504
|
-
diagnostics?.record('message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
|
|
501
|
+
diagnostics?.record('usage', 'message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
|
|
505
502
|
return;
|
|
506
503
|
}
|
|
507
504
|
try {
|
|
508
505
|
await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
|
|
509
|
-
diagnostics?.record('message-counted', 'confirmed');
|
|
510
506
|
if (['start', 'steer'].includes(message.turnKind) &&
|
|
511
507
|
plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
|
|
512
508
|
const key = `${message.threadId}:${message.turnId}`;
|
|
513
509
|
if (key.length <= 128) {
|
|
514
510
|
const counted = await meter.action(arm, room.id, arm.clientId, 'turn', key);
|
|
515
|
-
if (counted) diagnostics?.record('turn-
|
|
511
|
+
if (counted && message.turnKind === 'steer') diagnostics?.record('usage', 'turn-recovered', 'steer');
|
|
516
512
|
if (!room.usageTasks.has(key) && message.observerReady === true && message.completed !== true &&
|
|
517
513
|
(message.turnKind === 'start' || message.active === true)) {
|
|
518
514
|
const task = await meter.turnOrigin(room.id, key);
|
|
@@ -523,10 +519,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
523
519
|
}
|
|
524
520
|
}
|
|
525
521
|
}
|
|
526
|
-
} catch (error) { diagnostics?.record('message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
|
|
522
|
+
} catch (error) { diagnostics?.record('usage', 'message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
|
|
527
523
|
return;
|
|
528
524
|
}
|
|
529
|
-
if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
|
|
525
|
+
if (!['turn/started', 'turn/completed', 'heartbeat', 'resume', 'inactive'].includes(message.method) ||
|
|
530
526
|
!plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
|
|
531
527
|
const key = `${message.threadId}:${message.turnId}`;
|
|
532
528
|
if (key.length > 128) return;
|
|
@@ -534,14 +530,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
534
530
|
try {
|
|
535
531
|
if (message.method === 'resume' && !task) {
|
|
536
532
|
task = await meter.turnOrigin(room.id, key);
|
|
537
|
-
if (!task) { diagnostics?.record('turn-resume', 'origin-missing'); return; }
|
|
533
|
+
if (!task) { diagnostics?.record('usage', 'turn-resume', 'origin-missing'); return; }
|
|
538
534
|
await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
|
|
539
|
-
diagnostics?.record('turn-resume', 'confirmed');
|
|
535
|
+
diagnostics?.record('usage', 'turn-resume', 'confirmed');
|
|
540
536
|
room.usageTasks.set(key, task);
|
|
541
537
|
} else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
|
|
538
|
+
else if (task && message.method === 'inactive') {
|
|
539
|
+
await meter.lost(task);
|
|
540
|
+
room.usageTasks.delete(key);
|
|
541
|
+
}
|
|
542
542
|
else if (task && message.method === 'turn/completed') {
|
|
543
543
|
await meter.pulse(task, false, room.id, task.clientId);
|
|
544
|
-
diagnostics?.record('turn-completed', 'confirmed');
|
|
545
544
|
room.usageTasks.delete(key);
|
|
546
545
|
}
|
|
547
546
|
} catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const dayMs = 86400000;
|
|
2
|
+
const chinaOffsetMs = 8 * 3600000;
|
|
3
|
+
|
|
4
|
+
export function usageDaySlices(start, end) {
|
|
5
|
+
const slices = [];
|
|
6
|
+
for (let cursor = start; cursor < end;) {
|
|
7
|
+
const date = new Date(cursor + chinaOffsetMs).toISOString().slice(0, 10);
|
|
8
|
+
const nextDay = Date.parse(`${date}T00:00:00Z`) + dayMs - chinaOffsetMs;
|
|
9
|
+
const until = Math.min(end, nextDay);
|
|
10
|
+
slices.push({ date, start: cursor, end: until });
|
|
11
|
+
cursor = until;
|
|
12
|
+
}
|
|
13
|
+
return slices;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const readIntervals = value => Array.isArray(value) ? value : JSON.parse(value || '[]');
|
|
17
|
+
|
|
18
|
+
export function mergeUsageIntervals(existing, start, end) {
|
|
19
|
+
const sorted = [...existing, [start, end]].sort((a, b) => a[0] - b[0]);
|
|
20
|
+
const merged = [];
|
|
21
|
+
for (const range of sorted) {
|
|
22
|
+
const last = merged.at(-1);
|
|
23
|
+
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
|
|
24
|
+
else merged.push([...range]);
|
|
25
|
+
}
|
|
26
|
+
return { intervals: merged, seconds: merged.reduce((sum, [from, until]) => sum + until - from, 0) / 1000 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function recordDailyUsage(db, userId, computerId, free, start, end, now) {
|
|
30
|
+
for (const slice of usageDaySlices(start, end)) {
|
|
31
|
+
await db.execute(`INSERT IGNORE INTO relay_usage_user_days
|
|
32
|
+
(user_id,usage_date,intervals,updated_at) VALUES (?,?,'[]',?)`, [userId, slice.date, now]);
|
|
33
|
+
const [userRows] = await db.execute(`SELECT intervals FROM relay_usage_user_days
|
|
34
|
+
WHERE user_id=? AND usage_date=? FOR UPDATE`, [userId, slice.date]);
|
|
35
|
+
const user = mergeUsageIntervals(readIntervals(userRows[0].intervals), slice.start, slice.end);
|
|
36
|
+
await db.execute(`UPDATE relay_usage_user_days SET intervals=?,task_seconds=?,updated_at=?
|
|
37
|
+
WHERE user_id=? AND usage_date=?`,
|
|
38
|
+
[JSON.stringify(user.intervals), user.seconds, now, userId, slice.date]);
|
|
39
|
+
|
|
40
|
+
await db.execute(`INSERT IGNORE INTO relay_usage_computer_days
|
|
41
|
+
(computer_id,usage_date,intervals,free_intervals,updated_at) VALUES (?,?,'[]','[]',?)`,
|
|
42
|
+
[computerId, slice.date, now]);
|
|
43
|
+
const [computerRows] = await db.execute(`SELECT intervals,free_intervals FROM relay_usage_computer_days
|
|
44
|
+
WHERE computer_id=? AND usage_date=? FOR UPDATE`, [computerId, slice.date]);
|
|
45
|
+
const computer = mergeUsageIntervals(readIntervals(computerRows[0].intervals), slice.start, slice.end);
|
|
46
|
+
const freeIntervals = readIntervals(computerRows[0].free_intervals);
|
|
47
|
+
const freeComputer = free ? mergeUsageIntervals(freeIntervals, slice.start, slice.end) : null;
|
|
48
|
+
await db.execute(`UPDATE relay_usage_computer_days SET intervals=?,task_seconds=?,
|
|
49
|
+
free_intervals=?,free_task_seconds=COALESCE(?,free_task_seconds),updated_at=?
|
|
50
|
+
WHERE computer_id=? AND usage_date=?`,
|
|
51
|
+
[JSON.stringify(computer.intervals), computer.seconds,
|
|
52
|
+
JSON.stringify(freeComputer?.intervals || freeIntervals), freeComputer?.seconds ?? null,
|
|
53
|
+
now, computerId, slice.date]);
|
|
54
|
+
}
|
|
55
|
+
}
|