@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.
@@ -1,22 +1,19 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
- import { execFile } from 'node:child_process';
4
- import { promisify } from 'node:util';
5
- import { randomBytes, randomUUID } from 'node:crypto';
6
- import { hostname } from 'node:os';
7
- import QRCode from 'qrcode';
8
- import { credentialStore } from '../platform/windows/credential-store.mjs';
9
- import { readRecord, writeRecord } from '../service-lifecycle.mjs';
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 exec = promisify(execFile);
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 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
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 session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
69
- let health = await serviceHealth(session());
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
- const identities = stored.record || { version: 1, servers: {} };
73
- const validate = value => {
74
- if (value?.version !== 1 || !value.servers || typeof value.servers !== 'object' || Array.isArray(value.servers)) throw new Error('本机设备身份损坏,未自动覆盖');
75
- for (const [url, item] of Object.entries(value.servers)) {
76
- relayAddress(url);
77
- if (!validSecret(item.secret) || !/^[\w-]{16,128}$/.test(item.id)) throw new Error('本机设备身份损坏,未自动覆盖');
78
- }
79
- };
80
- validate(identities);
81
- let identity = identities.servers[config.url];
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
- identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
84
- identities.servers[config.url] = identity; await credentialStore('save', identities);
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 hostKey = identity.registered ? undefined : await registrationKey(config);
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, hostKey: identity.registered ? undefined : hostKey, identity,
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.servers[config.url] = 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;
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 = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
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 { usageDiagnostics } from './usage-diagnostics.mjs';
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('005_relay_usage_diagnostics')
36
- ? usageDiagnostics(database.pool, error => log({ event: 'usage-diagnostic-error', message: error.message })) : null;
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 user = target.searchParams.get('user') || '';
184
- if (!Number.isSafeInteger(page) || page < 1 || page > 100000 || (user && !/^[0-9a-f-]{36}$/.test(user))) {
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, user)); return;
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-counted', 'confirmed');
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
+ }