@walkhi/code-relax 0.1.0-beta.28 → 0.1.0-beta.29
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/codex-remote.mjs +21 -4
- package/dist/bin/self-relay-server.mjs +2 -0
- package/dist/src/self-relay/connector.mjs +1 -1
- package/dist/src/self-relay/email-accounts.mjs +35 -10
- package/dist/src/self-relay/lifecycle.mjs +22 -4
- package/dist/src/self-relay/long-pairing.mjs +40 -0
- package/dist/src/self-relay/server.mjs +36 -8
- package/dist/src/server.mjs +3 -0
- package/dist/src/service-control.mjs +13 -3
- package/package.json +1 -1
|
@@ -27,6 +27,9 @@ const help = `Code Relax(Windows)
|
|
|
27
27
|
常用指令:
|
|
28
28
|
start 启动或复用桌面服务和 Codex 内核,并恢复已配置的 Code Relax 中继
|
|
29
29
|
pair 启动或复用桌面服务,并打开一次性配对二维码
|
|
30
|
+
pair --expires <带时区的截止时间> 创建本工作站可重复使用的长期二维码
|
|
31
|
+
pair-list 查看本工作站长期邀请的 ID 和截止时间
|
|
32
|
+
pair-revoke <邀请ID> 废止本工作站指定长期邀请
|
|
30
33
|
dots-tasks 读取当前 ChatGPT 账号的 Dots 委派任务与最近轮次状态
|
|
31
34
|
维护指令:
|
|
32
35
|
setup 部署并启动桌面服务、Codex 内核和登录自启动;安全配置 Desktop 后续接入
|
|
@@ -53,10 +56,17 @@ async function main() {
|
|
|
53
56
|
console.log(help);
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
const options = flags.filter(flag => flag !== '--json');
|
|
60
|
+
const longPair = command === 'pair' && options.length === 2 && options[0] === '--expires';
|
|
61
|
+
const revokeInvite = command === 'pair-revoke' && options.length === 1 && /^[\w-]{16,128}$/.test(options[0]);
|
|
62
|
+
if (!['kernel-restart', 'desktop-recover', 'setup', 'doctor', 'uninstall', 'start', 'pair', 'pair-list', 'pair-revoke', 'dots-tasks'].includes(command)
|
|
63
|
+
|| (!longPair && !revokeInvite && (command === 'pair-revoke' || options.some(flag => !(['kernel-restart', 'desktop-recover'].includes(command) && flag === '--confirm'))))) {
|
|
58
64
|
throw new Error('无效命令或参数。使用 --help 查看用法。');
|
|
59
65
|
}
|
|
66
|
+
const inviteExpires = longPair ? Date.parse(options[1]) : undefined;
|
|
67
|
+
if (longPair && (!/(Z|[+-]\d{2}:\d{2})$/.test(options[1]) || !Number.isSafeInteger(inviteExpires) || inviteExpires <= Date.now())) {
|
|
68
|
+
throw new Error('截止时间必须在未来且含时区,例如 2026-11-01T00:00:00+08:00。');
|
|
69
|
+
}
|
|
60
70
|
if (process.platform !== 'win32') throw new Error('当前被控端仅支持 Windows。');
|
|
61
71
|
const json = flags.includes('--json');
|
|
62
72
|
if (command === 'dots-tasks') {
|
|
@@ -79,7 +89,7 @@ async function main() {
|
|
|
79
89
|
&& ['start', 'pair', 'doctor'].includes(command)) {
|
|
80
90
|
writeRecord(installationPath, { ...installation, nodeExecutable: currentNodeExecutable() });
|
|
81
91
|
}
|
|
82
|
-
if (['kernel-restart', 'desktop-recover', 'start', 'pair'].includes(command) && !installed) {
|
|
92
|
+
if (['kernel-restart', 'desktop-recover', 'start', 'pair', 'pair-list', 'pair-revoke'].includes(command) && !installed) {
|
|
83
93
|
if (!fs.existsSync(installedEntry)) throw new Error('尚未部署服务,请先执行 relax setup。');
|
|
84
94
|
const executable = readRecord(installationPath)?.nodeExecutable;
|
|
85
95
|
if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新执行 relax setup。');
|
|
@@ -139,6 +149,12 @@ async function main() {
|
|
|
139
149
|
console.log(json ? JSON.stringify(result) : `${result.message || result.status}${result.error ? '\n' + result.error : ''}\n${result.operationId || ''}`.trim());
|
|
140
150
|
return;
|
|
141
151
|
}
|
|
152
|
+
if (command === 'pair-list' || command === 'pair-revoke') {
|
|
153
|
+
const result = await selfRelay(command === 'pair-list' ? 'invite-list' : 'invite-revoke', stateRoot,
|
|
154
|
+
command === 'pair-revoke' ? { id: options[0] } : undefined);
|
|
155
|
+
console.log(json ? JSON.stringify(result) : result.displayText);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
142
158
|
if (command === 'pair') {
|
|
143
159
|
await ensureAutostart();
|
|
144
160
|
let bridge = await service.status();
|
|
@@ -147,7 +163,8 @@ async function main() {
|
|
|
147
163
|
bridge = await service.status();
|
|
148
164
|
}
|
|
149
165
|
if (!bridge.running) throw new Error('Code Relax 工作站服务未能启动。');
|
|
150
|
-
|
|
166
|
+
if (longPair) await selfRelay('relay-start', stateRoot);
|
|
167
|
+
const result = await selfRelay(longPair ? 'invite-create' : 'relay-pair', stateRoot, longPair ? { expires: inviteExpires } : undefined);
|
|
151
168
|
if (!json && result.qrPath) {
|
|
152
169
|
const viewer = spawn('explorer.exe', [result.qrPath], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
153
170
|
viewer.unref();
|
|
@@ -14,6 +14,7 @@ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Inv
|
|
|
14
14
|
const stateFile = process.env.RELAY_STATE_FILE;
|
|
15
15
|
const mysqlConfigFile = process.env.RELAY_MYSQL_CONFIG_FILE;
|
|
16
16
|
const accountConfigFile = process.env.RELAY_ACCOUNT_CONFIG_FILE;
|
|
17
|
+
const longPairingFile = process.env.RELAY_LONG_PAIRING_FILE || '/etc/codex-relay/long-pairing.json';
|
|
17
18
|
if (!stateFile && !mysqlConfigFile) throw new Error('RELAY_STATE_FILE or RELAY_MYSQL_CONFIG_FILE is required');
|
|
18
19
|
const debugAllowlistFile = process.env.RELAY_DEBUG_ALLOWLIST_FILE || '/etc/codex-relay/debug-allowlist.json';
|
|
19
20
|
const adminStateFile = process.env.RELAY_ADMIN_STATE_FILE || '/var/lib/codex-relay/admin.json';
|
|
@@ -53,6 +54,7 @@ if (revokeIndex >= 0) {
|
|
|
53
54
|
console.log('Computer and phone authorization revoked'); process.exit(0);
|
|
54
55
|
}
|
|
55
56
|
const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
|
|
57
|
+
longPairingFile,
|
|
56
58
|
usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
|
|
57
59
|
publicRelayUrl: process.env.RELAY_PUBLIC_URL || serviceEndpoints.relayUrl, appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || serviceEndpoints.installUrl,
|
|
58
60
|
log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
|
|
@@ -243,7 +243,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
243
243
|
resolve(message); return;
|
|
244
244
|
}
|
|
245
245
|
if (!registered) throw new Error('Relay not registered');
|
|
246
|
-
if (
|
|
246
|
+
if (['pair-code', 'revoked', 'status', 'invite-create', 'invite-list', 'invite-revoke'].includes(message.type)) {
|
|
247
247
|
if (operation && message.commandId === operation.id && message.type === operation.responseType) {
|
|
248
248
|
clearTimeout(operation.timer); const pending = operation; operation = undefined;
|
|
249
249
|
message.error ? pending.reject(new Error(message.error)) : pending.resolve(message);
|
|
@@ -27,6 +27,12 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
|
|
|
27
27
|
const provider = providerOverride || tencentAccountProvider(config);
|
|
28
28
|
const codeHash = (email, code) => createHmac('sha256', config.codePepper).update(`${email}\0${code}`).digest('hex');
|
|
29
29
|
const scopeHash = value => createHmac('sha256', config.codePepper).update(value).digest('hex');
|
|
30
|
+
const review = config.reviewLogin?.enabled === true ? config.reviewLogin : null;
|
|
31
|
+
if (review && (review.email !== normalizeEmail(review.email) || !/^[a-f0-9]{64}$/.test(review.codeHash || '')
|
|
32
|
+
|| !Number.isFinite(Date.parse(review.startsAt)) || !Number.isFinite(Date.parse(review.expiresAt))
|
|
33
|
+
|| Date.parse(review.startsAt) >= Date.parse(review.expiresAt))) {
|
|
34
|
+
throw new Error('Invalid review login configuration');
|
|
35
|
+
}
|
|
30
36
|
|
|
31
37
|
async function requestCode({ email: rawEmail, ticket, randstr }, ip) {
|
|
32
38
|
const email = normalizeEmail(rawEmail);
|
|
@@ -78,17 +84,33 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
|
|
|
78
84
|
const db = await pool.getConnection();
|
|
79
85
|
try {
|
|
80
86
|
await db.beginTransaction();
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
const reviewActive = review?.email === email && Date.now() >= Date.parse(review.startsAt)
|
|
88
|
+
&& Date.now() < Date.parse(review.expiresAt);
|
|
89
|
+
let reviewLogin = false;
|
|
90
|
+
if (reviewActive) {
|
|
91
|
+
// Reuse the persisted rate-limit table so restarts cannot reset guesses.
|
|
92
|
+
const windowStart = sqlTime(new Date(Math.floor(Date.now() / 3600000) * 3600000).toISOString()).slice(0, 19);
|
|
93
|
+
const key = scopeHash(`review-login:${email}`);
|
|
94
|
+
await db.execute('INSERT INTO email_send_limits (scope_hash,window_start,request_count) VALUES (?,?,1) ON DUPLICATE KEY UPDATE request_count=request_count+1', [key, windowStart]);
|
|
95
|
+
const [attempts] = await db.execute('SELECT request_count FROM email_send_limits WHERE scope_hash=? AND window_start=? FOR UPDATE', [key, windowStart]);
|
|
88
96
|
await db.commit();
|
|
89
|
-
throw error(
|
|
97
|
+
if (attempts[0].request_count > 10) throw error(429, 'Too many login attempts');
|
|
98
|
+
await db.beginTransaction();
|
|
99
|
+
reviewLogin = timingSafeEqual(Buffer.from(codeHash(email, code), 'hex'), Buffer.from(review.codeHash, 'hex'));
|
|
100
|
+
}
|
|
101
|
+
if (!reviewLogin) {
|
|
102
|
+
const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
|
|
103
|
+
const challenge = challenges[0];
|
|
104
|
+
if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
|
|
105
|
+
const supplied = Buffer.from(codeHash(email, code), 'hex');
|
|
106
|
+
if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
|
|
107
|
+
if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
|
|
108
|
+
else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
|
|
109
|
+
await db.commit();
|
|
110
|
+
throw error(401, 'Code expired or incorrect');
|
|
90
111
|
}
|
|
91
112
|
await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
|
|
113
|
+
}
|
|
92
114
|
const [identities] = await db.execute(`SELECT u.id,u.status FROM user_login_identities i
|
|
93
115
|
JOIN users u ON u.id=i.user_id WHERE i.kind='email' AND i.normalized_value=?`, [email]);
|
|
94
116
|
let userId = identities[0]?.id;
|
|
@@ -101,10 +123,13 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
|
|
|
101
123
|
VALUES (?,'email',?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))`, [userId, email]);
|
|
102
124
|
}
|
|
103
125
|
const token = randomBytes(32).toString('base64url');
|
|
126
|
+
const expiresAt = Math.min(Date.now() + 30 * 86400000, reviewLogin ? Date.parse(review.expiresAt) : Infinity);
|
|
127
|
+
if (expiresAt <= Date.now()) throw error(401, 'Code expired or incorrect');
|
|
104
128
|
await db.execute(`INSERT INTO user_sessions (token_hash,user_id,created_at,expires_at)
|
|
105
|
-
VALUES (?,?,UTC_TIMESTAMP(3)
|
|
129
|
+
VALUES (?,?,UTC_TIMESTAMP(3),?)`, [tokenHash(token), userId, sqlTime(new Date(expiresAt).toISOString())]);
|
|
106
130
|
await db.commit();
|
|
107
|
-
|
|
131
|
+
if (reviewLogin) console.info(JSON.stringify({ event: 'review-login', userId, expiresAt: new Date(expiresAt).toISOString() }));
|
|
132
|
+
return { token, user: { id: userId, email }, expiresInSeconds: Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) };
|
|
108
133
|
} catch (failure) { await db.rollback(); throw failure; }
|
|
109
134
|
finally { db.release(); }
|
|
110
135
|
}
|
|
@@ -33,14 +33,14 @@ async function loadConfig(stateRoot) {
|
|
|
33
33
|
(pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
|
|
34
34
|
return { ...config, url: resolved, pairingUrl: pairing.href };
|
|
35
35
|
}
|
|
36
|
-
export async function selfRelay(command, stateRoot) {
|
|
36
|
+
export async function selfRelay(command, stateRoot, details) {
|
|
37
37
|
const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
38
38
|
if (!session) {
|
|
39
39
|
if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
|
|
40
40
|
if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
|
|
41
41
|
throw new Error('桌面服务尚未运行,请先执行 relax start。');
|
|
42
42
|
}
|
|
43
|
-
return controlRequest(session, 'POST', command);
|
|
43
|
+
return controlRequest(session, 'POST', command, details);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
async function createRelayRuntime(stateRoot) {
|
|
@@ -186,6 +186,22 @@ async function createRelayRuntime(stateRoot) {
|
|
|
186
186
|
await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
|
|
187
187
|
return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
|
|
188
188
|
}
|
|
189
|
+
async function invite(command, details) {
|
|
190
|
+
if (!online || closing) throw new Error('中继尚未连接,请先执行 relax start。');
|
|
191
|
+
const result = await connector.command(command, command === 'invite-create' ? { expires: details.expires } : command === 'invite-revoke' ? { id: details.id } : {});
|
|
192
|
+
if (command === 'invite-create') {
|
|
193
|
+
const link = new URL(config.pairingUrl);
|
|
194
|
+
link.searchParams.set('invite', result.code);
|
|
195
|
+
const inviteQrPath = path.join(stateRoot, `self-relay-long-qr-${result.id}.png`);
|
|
196
|
+
try { await QRCode.toFile(inviteQrPath, link.href, { width: 640, margin: 3 }); }
|
|
197
|
+
catch (error) { throw new Error(`二维码保存失败:${error.message}。邀请 ID:${result.id};请执行 relax pair-revoke ${result.id} 废止。`); }
|
|
198
|
+
return { invitationId: result.id, deviceId: result.room, expiresAt: new Date(result.expires).toISOString(), qrPath: inviteQrPath,
|
|
199
|
+
displayText: `长期配对邀请已创建。邀请 ID:${result.id}\n截止:${new Date(result.expires).toISOString()}` };
|
|
200
|
+
}
|
|
201
|
+
if (command === 'invite-list') return { invitations: result.invitations,
|
|
202
|
+
displayText: result.invitations.length ? result.invitations.map(item => `${item.id} ${new Date(item.expires).toISOString()}${Date.now() >= item.expires ? '(已过期)' : ''}`).join('\n') : '本工作站没有长期配对邀请。' };
|
|
203
|
+
return { invitationId: result.id, revoked: result.revoked, displayText: '长期邀请已废止;已配对手机的独立授权保持不变。' };
|
|
204
|
+
}
|
|
189
205
|
async function refreshLan() {
|
|
190
206
|
if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
|
|
191
207
|
await refreshHealth();
|
|
@@ -201,14 +217,14 @@ async function createRelayRuntime(stateRoot) {
|
|
|
201
217
|
}
|
|
202
218
|
return status();
|
|
203
219
|
};
|
|
204
|
-
try { await connect(); return { status, inspect, pair, revoke, refreshLan, shutdown }; }
|
|
220
|
+
try { await connect(); return { status, inspect, pair, revoke, invite, refreshLan, shutdown }; }
|
|
205
221
|
catch (error) { await shutdown(); throw error; }
|
|
206
222
|
}
|
|
207
223
|
|
|
208
224
|
export function createRelayManager(stateRoot) {
|
|
209
225
|
let current;
|
|
210
226
|
let operation = Promise.resolve();
|
|
211
|
-
const run = command => {
|
|
227
|
+
const run = (command, details = {}) => {
|
|
212
228
|
const next = operation.then(async () => {
|
|
213
229
|
if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
|
|
214
230
|
if (command === 'relay-stop') {
|
|
@@ -216,10 +232,12 @@ export function createRelayManager(stateRoot) {
|
|
|
216
232
|
return { stopped: true, displayText: '中继连接已停止;桌面服务和 Codex 内核保持运行。' };
|
|
217
233
|
}
|
|
218
234
|
if (!current) {
|
|
235
|
+
if (command.startsWith('invite-')) throw new Error('请先执行 relax start 连接中继。');
|
|
219
236
|
if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
|
|
220
237
|
current = await createRelayRuntime(stateRoot);
|
|
221
238
|
return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
|
|
222
239
|
}
|
|
240
|
+
if (command.startsWith('invite-')) return current.invite(command, details);
|
|
223
241
|
if (command === 'relay-revoke') return current.revoke();
|
|
224
242
|
if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
|
|
225
243
|
if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { validId } from './device-store.mjs';
|
|
4
|
+
import { digest, matches, secret } from './wire.mjs';
|
|
5
|
+
|
|
6
|
+
// Operator-owned file, separate from device state. Only hashes are retained here.
|
|
7
|
+
export function longPairing(file) {
|
|
8
|
+
function read() {
|
|
9
|
+
if (!file || !fs.existsSync(file)) return [];
|
|
10
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
11
|
+
if (data.version !== 1 || !Array.isArray(data.invitations) || data.invitations.some(item =>
|
|
12
|
+
!validId(item.id) || !validId(item.room) || !/^[a-f0-9]{64}$/.test(item.hash) ||
|
|
13
|
+
!Number.isSafeInteger(item.expires))) throw new Error('Invalid long pairing invitations');
|
|
14
|
+
return data.invitations;
|
|
15
|
+
}
|
|
16
|
+
function write(invitations) {
|
|
17
|
+
if (!file) throw new Error('Long pairing file is not configured');
|
|
18
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(temporary, JSON.stringify({ version: 1, invitations }), { mode: 0o600, flag: 'wx' });
|
|
21
|
+
fs.renameSync(temporary, file);
|
|
22
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
list: () => read().map(({ hash, ...item }) => item),
|
|
26
|
+
find: code => read().find(item => Date.now() < item.expires && matches(code, item.hash)),
|
|
27
|
+
create(room, expires) {
|
|
28
|
+
if (!validId(room) || !Number.isSafeInteger(expires) || expires <= Date.now()) throw new Error('Invalid invitation room or expiry');
|
|
29
|
+
const invitations = read(), code = secret();
|
|
30
|
+
const item = { id: randomUUID(), room, expires, hash: digest(code) };
|
|
31
|
+
write([...invitations, item]);
|
|
32
|
+
return { id: item.id, room, expires, code };
|
|
33
|
+
},
|
|
34
|
+
revoke(id) {
|
|
35
|
+
const invitations = read();
|
|
36
|
+
if (!invitations.some(item => item.id === id)) throw new Error('Invitation not found');
|
|
37
|
+
write(invitations.filter(item => item.id !== id));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { privacyPage } from './privacy-page.mjs';
|
|
2
|
+
import { longPairing } from './long-pairing.mjs';
|
|
2
3
|
import http from 'node:http';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import { isIP } from 'node:net';
|
|
@@ -20,7 +21,13 @@ import { relayDiagnostics } from './diagnostics.mjs';
|
|
|
20
21
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
21
22
|
maxConnections = 128, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
|
|
22
23
|
maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
|
|
23
|
-
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
|
|
24
|
+
publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, longPairingFile, log = () => {} } = {}) {
|
|
25
|
+
const longInvitations = longPairing(longPairingFile);
|
|
26
|
+
longInvitations.list();
|
|
27
|
+
function longInvitation(code) {
|
|
28
|
+
try { return longInvitations.find(code); }
|
|
29
|
+
catch (error) { log({ event: 'long-pairing-error', message: error.message }); return undefined; }
|
|
30
|
+
}
|
|
24
31
|
if (!validSecret(hostKey)) throw new Error('Invalid host credential');
|
|
25
32
|
if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
|
|
26
33
|
if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
|
|
@@ -93,7 +100,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
93
100
|
}
|
|
94
101
|
function findInvitation(code) {
|
|
95
102
|
if (!validSecret(code)) return undefined;
|
|
96
|
-
|
|
103
|
+
const room = [...rooms.values()].find(room => room.status === 'active' && room.pairingHash && Date.now() < room.expires && matches(code, room.pairingHash));
|
|
104
|
+
if (room) return { room, expires: room.expires };
|
|
105
|
+
const invite = longInvitation(code), target = invite && rooms.get(invite.room);
|
|
106
|
+
return target?.status === 'active' ? { room: target, expires: invite.expires } : undefined;
|
|
97
107
|
}
|
|
98
108
|
const server = http.createServer((request, response) => {
|
|
99
109
|
const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
@@ -121,19 +131,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
121
131
|
response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
|
|
122
132
|
}
|
|
123
133
|
const code = target.searchParams.get('invite') || '';
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
134
|
+
const invitation = findInvitation(code);
|
|
135
|
+
if (!invitation) {
|
|
126
136
|
const json = target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '');
|
|
127
137
|
const body = json ? JSON.stringify({ error: '配对邀请已过期或已使用' }) : pairingPage({ expired: true });
|
|
128
138
|
response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
|
|
129
139
|
}
|
|
130
140
|
if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
|
|
131
|
-
const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires:
|
|
141
|
+
const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: invitation.room.id, expires: invitation.expires });
|
|
132
142
|
response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
|
|
133
143
|
}
|
|
134
144
|
const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
|
|
135
145
|
const current = new URL(target.pathname + target.search, `${secure ? 'https' : 'http'}://${request.headers.host || new URL(listenUrl).host}`);
|
|
136
|
-
const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires:
|
|
146
|
+
const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: invitation.expires, appDownloadUrl });
|
|
137
147
|
response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
|
|
138
148
|
});
|
|
139
149
|
function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
@@ -541,7 +551,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
541
551
|
room = rooms.get(message.room);
|
|
542
552
|
if (!room || room.status !== 'active') return fail();
|
|
543
553
|
let credential;
|
|
544
|
-
|
|
554
|
+
const longInvite = message.type === 'pair' ? longInvitation(message.code) : undefined;
|
|
555
|
+
const reusableInvite = longInvite?.room === room.id;
|
|
556
|
+
if (message.type === 'pair' && !room.pairingInProgress && (reusableInvite ||
|
|
557
|
+
(room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)))) {
|
|
545
558
|
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
546
559
|
credential = message.credential || secret();
|
|
547
560
|
clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
|
|
@@ -553,7 +566,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
553
566
|
room.pairingInProgress = true;
|
|
554
567
|
try {
|
|
555
568
|
await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
|
|
556
|
-
room.pairingHash = undefined;
|
|
569
|
+
if (!reusableInvite) room.pairingHash = undefined;
|
|
557
570
|
} finally { room.pairingInProgress = false; }
|
|
558
571
|
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
559
572
|
send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
|
|
@@ -695,6 +708,21 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
695
708
|
if (message.type === 'pair-code') {
|
|
696
709
|
send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
|
|
697
710
|
}
|
|
711
|
+
if (['invite-create', 'invite-list', 'invite-revoke'].includes(message.type)) {
|
|
712
|
+
try {
|
|
713
|
+
if (!room.durable) throw new Error('长期邀请需要持久工作站身份');
|
|
714
|
+
let result;
|
|
715
|
+
if (message.type === 'invite-create') result = longInvitations.create(room.id, message.expires);
|
|
716
|
+
else if (message.type === 'invite-list') result = { invitations: longInvitations.list().filter(item => item.room === room.id) };
|
|
717
|
+
else {
|
|
718
|
+
if (!longInvitations.list().some(item => item.id === message.id && item.room === room.id)) throw new Error('未找到本工作站的邀请');
|
|
719
|
+
longInvitations.revoke(message.id);
|
|
720
|
+
result = { id: message.id, revoked: true };
|
|
721
|
+
}
|
|
722
|
+
send(ws, { type: message.type, commandId: message.commandId, ...result });
|
|
723
|
+
} catch (error) { send(ws, { type: message.type, commandId: message.commandId, error: error.message }); }
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
698
726
|
if (message.type === 'revoke') {
|
|
699
727
|
await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
|
|
700
728
|
room.pairingHash = undefined;
|
package/dist/src/server.mjs
CHANGED
|
@@ -819,6 +819,9 @@ server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
|
|
|
819
819
|
'relay-stop': () => relayManager.run('relay-stop'),
|
|
820
820
|
'relay-pair': () => relayManager.run('relay-pair'),
|
|
821
821
|
'relay-revoke': () => relayManager.run('relay-revoke'),
|
|
822
|
+
'invite-create': details => relayManager.run('invite-create', details),
|
|
823
|
+
'invite-list': () => relayManager.run('invite-list'),
|
|
824
|
+
'invite-revoke': details => relayManager.run('invite-revoke', details),
|
|
822
825
|
'restart-managed-app-server': () => {
|
|
823
826
|
const state = managedAppServer.snapshot();
|
|
824
827
|
return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
|
|
@@ -22,7 +22,17 @@ export async function openControl({ instanceId, secret, shutdown, status = () =>
|
|
|
22
22
|
response.setHeader('Content-Type', 'application/json');
|
|
23
23
|
response.setHeader('Connection', 'close');
|
|
24
24
|
if (action) {
|
|
25
|
-
Promise.resolve().then(
|
|
25
|
+
Promise.resolve().then(async () => {
|
|
26
|
+
let body = '';
|
|
27
|
+
request.setEncoding('utf8');
|
|
28
|
+
for await (const chunk of request) {
|
|
29
|
+
body += chunk;
|
|
30
|
+
if (Buffer.byteLength(body) > 4096) throw new Error('管理请求过大');
|
|
31
|
+
}
|
|
32
|
+
const details = body ? JSON.parse(body) : {};
|
|
33
|
+
if (!details || typeof details !== 'object' || Array.isArray(details)) throw new Error('无效管理请求');
|
|
34
|
+
return actions[action](details);
|
|
35
|
+
}).then(result => {
|
|
26
36
|
response.end(JSON.stringify({ ...result, instanceId, pid: process.pid }));
|
|
27
37
|
}).catch(error => { response.writeHead(409).end(JSON.stringify({ error: error.message })); });
|
|
28
38
|
return;
|
|
@@ -44,7 +54,7 @@ export async function openControl({ instanceId, secret, shutdown, status = () =>
|
|
|
44
54
|
}) };
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
export function controlRequest(session, method = 'GET', action = '') {
|
|
57
|
+
export function controlRequest(session, method = 'GET', action = '', details) {
|
|
48
58
|
return new Promise((resolve, reject) => {
|
|
49
59
|
const request = http.request({ hostname: '127.0.0.1', port: session.controlPort,
|
|
50
60
|
path: `/${session.instanceId}${action ? '/' + action : ''}`, method, agent: false,
|
|
@@ -67,6 +77,6 @@ export function controlRequest(session, method = 'GET', action = '') {
|
|
|
67
77
|
});
|
|
68
78
|
request.on('timeout', () => request.destroy(new Error('桌面服务管理通道响应超时。')));
|
|
69
79
|
request.on('error', reject);
|
|
70
|
-
request.end();
|
|
80
|
+
request.end(details === undefined ? undefined : JSON.stringify(details));
|
|
71
81
|
});
|
|
72
82
|
}
|