@walkhi/code-relax 0.1.0-beta.11 → 0.1.0-beta.13
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 +42 -6
- package/dist/bin/migrate-relay-db.mjs +104 -0
- package/dist/bin/self-relay-server.mjs +17 -10
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/src/managed-app-server.mjs +13 -4
- package/dist/src/platform/windows/codex-runtime.mjs +138 -4
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
- package/dist/src/platform/windows/resident-start.js +8 -0
- package/dist/src/platform/windows/resident-startup.ps1 +6 -3
- package/dist/src/self-relay/admin-state.mjs +8 -6
- package/dist/src/self-relay/email-accounts.mjs +125 -0
- package/dist/src/self-relay/lifecycle.mjs +48 -80
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/server.mjs +88 -26
- package/dist/src/server.mjs +745 -726
- package/dist/src/shared-app-server.mjs +285 -285
- package/package.json +2 -1
|
@@ -5,7 +5,9 @@ $ErrorActionPreference = 'Stop'
|
|
|
5
5
|
$taskName = 'Code Relax Bridge'
|
|
6
6
|
$taskSource = '@walkhi/code-relax'
|
|
7
7
|
$taskPath = '\' + $taskName
|
|
8
|
-
$
|
|
8
|
+
$launcher = Join-Path $PSScriptRoot 'resident-start.js'
|
|
9
|
+
$wscript = Join-Path $env:WINDIR 'System32\wscript.exe'
|
|
10
|
+
$arguments = '//B //Nologo "' + $launcher + '" "' + $NodePath + '" "' + $Entry + '"'
|
|
9
11
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
10
12
|
|
|
11
13
|
$scheduler = New-Object -ComObject 'Schedule.Service'
|
|
@@ -30,7 +32,8 @@ if ($Action -ne 'status' -and $legacyExists) {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
if ($Action -eq 'enable') {
|
|
33
|
-
if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry)
|
|
35
|
+
if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry) -or
|
|
36
|
+
-not (Test-Path -LiteralPath $launcher) -or -not (Test-Path -LiteralPath $wscript)) { throw 'Installed runtime is missing.' }
|
|
34
37
|
$definition = $scheduler.NewTask(0)
|
|
35
38
|
$definition.RegistrationInfo.Description = 'Start the Code Relax Bridge when this user signs in.'
|
|
36
39
|
$definition.RegistrationInfo.Source = $taskSource
|
|
@@ -48,7 +51,7 @@ if ($Action -eq 'enable') {
|
|
|
48
51
|
$trigger.UserId = $currentUser
|
|
49
52
|
$trigger.Enabled = $true
|
|
50
53
|
$exec = $definition.Actions.Create(0) # TASK_ACTION_EXEC
|
|
51
|
-
$exec.Path = $
|
|
54
|
+
$exec.Path = $wscript
|
|
52
55
|
$exec.Arguments = $arguments
|
|
53
56
|
$exec.WorkingDirectory = $env:USERPROFILE
|
|
54
57
|
[void]$root.RegisterTaskDefinition($taskName, $definition, 6, $null, $null, 3, $null) # TASK_CREATE_OR_UPDATE
|
|
@@ -2,19 +2,20 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
|
|
5
|
-
export function relayAdminState(file, initialMaxRelaySessions) {
|
|
6
|
-
let value = { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
5
|
+
export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
|
|
6
|
+
let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
7
7
|
if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
8
8
|
|
|
9
|
-
function save(next) {
|
|
10
|
-
|
|
11
|
-
if (
|
|
9
|
+
async function save(next) {
|
|
10
|
+
const validated = validate(next);
|
|
11
|
+
if (persist) await persist(validated);
|
|
12
|
+
if (!file) { value = validated; return value; }
|
|
12
13
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
13
14
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
14
15
|
let descriptor;
|
|
15
16
|
try {
|
|
16
17
|
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
17
|
-
fs.writeFileSync(descriptor, JSON.stringify(
|
|
18
|
+
fs.writeFileSync(descriptor, JSON.stringify(validated));
|
|
18
19
|
fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
|
|
19
20
|
fs.renameSync(temporary, file);
|
|
20
21
|
if (process.platform !== 'win32') {
|
|
@@ -25,6 +26,7 @@ export function relayAdminState(file, initialMaxRelaySessions) {
|
|
|
25
26
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
26
27
|
fs.rmSync(temporary, { force: true });
|
|
27
28
|
}
|
|
29
|
+
value = validated;
|
|
28
30
|
return value;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createHash, createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { tencentAccountProvider } from '../../relay-admin/tencent-account-provider.mjs';
|
|
4
|
+
|
|
5
|
+
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
6
|
+
const isoTime = value => value.replace(' ', 'T') + 'Z';
|
|
7
|
+
const error = (status, message) => Object.assign(new Error(message), { status });
|
|
8
|
+
const tokenHash = token => createHash('sha256').update(token).digest('hex');
|
|
9
|
+
const normalizeEmail = value => {
|
|
10
|
+
if (typeof value !== 'string') throw error(400, 'Invalid email address');
|
|
11
|
+
const email = value.trim().toLowerCase();
|
|
12
|
+
if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(email)) {
|
|
13
|
+
throw error(400, 'Invalid email address');
|
|
14
|
+
}
|
|
15
|
+
return email;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function openEmailAccounts(pool, configFile, providerOverride) {
|
|
19
|
+
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
20
|
+
if (![config.secretId, config.secretKey, config.captchaAppSecretKey, config.fromEmailAddress, config.sesRegion]
|
|
21
|
+
.every(value => typeof value === 'string' && value.length > 0) ||
|
|
22
|
+
!Number.isSafeInteger(config.captchaAppId) || config.captchaAppId < 1 ||
|
|
23
|
+
!Number.isSafeInteger(config.emailTemplateId) || config.emailTemplateId < 1 ||
|
|
24
|
+
typeof config.codePepper !== 'string' || config.codePepper.length < 32) {
|
|
25
|
+
throw new Error('Incomplete relay account configuration');
|
|
26
|
+
}
|
|
27
|
+
const provider = providerOverride || tencentAccountProvider(config);
|
|
28
|
+
const codeHash = (email, code) => createHmac('sha256', config.codePepper).update(`${email}\0${code}`).digest('hex');
|
|
29
|
+
const scopeHash = value => createHmac('sha256', config.codePepper).update(value).digest('hex');
|
|
30
|
+
|
|
31
|
+
async function requestCode({ email: rawEmail, ticket, randstr }, ip) {
|
|
32
|
+
const email = normalizeEmail(rawEmail);
|
|
33
|
+
if (typeof ticket !== 'string' || !ticket || ticket.length > 2048 ||
|
|
34
|
+
typeof randstr !== 'string' || !randstr || randstr.length > 128) throw error(400, 'Invalid human verification');
|
|
35
|
+
if (!ip || ip.length > 64) throw error(400, 'Invalid client address');
|
|
36
|
+
// A cheap pre-check precedes the billable human verification.
|
|
37
|
+
const [recent] = await pool.execute('SELECT sent_at FROM email_login_challenges WHERE email = ? AND sent_at > UTC_TIMESTAMP(3) - INTERVAL 60 SECOND', [email]);
|
|
38
|
+
if (recent.length) throw error(429, 'Please wait before requesting another code');
|
|
39
|
+
const [ipLimit] = await pool.execute(`SELECT request_count FROM email_send_limits
|
|
40
|
+
WHERE scope_hash=? AND window_start>=UTC_TIMESTAMP() - INTERVAL 1 HOUR ORDER BY window_start DESC LIMIT 1`, [scopeHash(`ip:${ip}`)]);
|
|
41
|
+
if (ipLimit[0]?.request_count >= 20) throw error(429, 'Too many email requests');
|
|
42
|
+
if (!await provider.verifyHuman(ticket, randstr, ip)) throw error(403, 'Human verification failed');
|
|
43
|
+
const code = String(randomInt(0, 1000000)).padStart(6, '0');
|
|
44
|
+
const hash = codeHash(email, code);
|
|
45
|
+
const windowStart = sqlTime(new Date(Math.floor(Date.now() / 3600000) * 3600000).toISOString()).slice(0, 19);
|
|
46
|
+
const db = await pool.getConnection();
|
|
47
|
+
try {
|
|
48
|
+
await db.beginTransaction();
|
|
49
|
+
for (const [scope, limit] of [[`email:${email}`, 5], [`ip:${ip}`, 20]]) {
|
|
50
|
+
const key = scopeHash(scope);
|
|
51
|
+
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]);
|
|
52
|
+
const [rows] = await db.execute('SELECT request_count FROM email_send_limits WHERE scope_hash=? AND window_start=? FOR UPDATE', [key, windowStart]);
|
|
53
|
+
if (rows[0].request_count > limit) throw error(429, 'Too many email requests');
|
|
54
|
+
}
|
|
55
|
+
const [existing] = await db.execute('SELECT sent_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
|
|
56
|
+
if (existing.length && Date.now() - new Date(`${existing[0].sent_at.replace(' ', 'T')}Z`).getTime() < 60000) {
|
|
57
|
+
throw error(429, 'Please wait before requesting another code');
|
|
58
|
+
}
|
|
59
|
+
await db.execute(`INSERT INTO email_login_challenges (email,code_hash,expires_at,attempts,sent_at)
|
|
60
|
+
VALUES (?, ?, UTC_TIMESTAMP(3) + INTERVAL 10 MINUTE, 0, UTC_TIMESTAMP(3))
|
|
61
|
+
ON DUPLICATE KEY UPDATE code_hash=VALUES(code_hash),expires_at=VALUES(expires_at),attempts=0,sent_at=VALUES(sent_at)`, [email, hash]);
|
|
62
|
+
await db.commit();
|
|
63
|
+
} catch (failure) { await db.rollback(); throw failure; }
|
|
64
|
+
finally { db.release(); }
|
|
65
|
+
try { await provider.sendLoginCode(email, code); }
|
|
66
|
+
catch (failure) {
|
|
67
|
+
await pool.execute('DELETE FROM email_login_challenges WHERE email=? AND code_hash=?', [email, hash]);
|
|
68
|
+
throw error(503, 'Email delivery unavailable');
|
|
69
|
+
}
|
|
70
|
+
return { sent: true, expiresInSeconds: 600 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function verifyCode({ email: rawEmail, code }) {
|
|
74
|
+
const email = normalizeEmail(rawEmail);
|
|
75
|
+
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) throw error(400, 'Invalid code');
|
|
76
|
+
const db = await pool.getConnection();
|
|
77
|
+
try {
|
|
78
|
+
await db.beginTransaction();
|
|
79
|
+
const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
|
|
80
|
+
const challenge = challenges[0];
|
|
81
|
+
if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
|
|
82
|
+
const supplied = Buffer.from(codeHash(email, code), 'hex');
|
|
83
|
+
if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
|
|
84
|
+
if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
|
|
85
|
+
else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
|
|
86
|
+
await db.commit();
|
|
87
|
+
throw error(401, 'Code expired or incorrect');
|
|
88
|
+
}
|
|
89
|
+
await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
|
|
90
|
+
const [identities] = await db.execute(`SELECT u.id,u.status FROM user_login_identities i
|
|
91
|
+
JOIN users u ON u.id=i.user_id WHERE i.kind='email' AND i.normalized_value=?`, [email]);
|
|
92
|
+
let userId = identities[0]?.id;
|
|
93
|
+
if (identities[0]?.status === 'disabled') throw error(403, 'Account unavailable');
|
|
94
|
+
if (!userId) {
|
|
95
|
+
userId = randomUUID();
|
|
96
|
+
await db.execute('INSERT INTO users (id,status,created_at,updated_at) VALUES (?,\'active\',UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))', [userId]);
|
|
97
|
+
await db.execute(`INSERT INTO user_login_identities (user_id,kind,normalized_value,verified_at,created_at)
|
|
98
|
+
VALUES (?,'email',?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))`, [userId, email]);
|
|
99
|
+
}
|
|
100
|
+
const token = randomBytes(32).toString('base64url');
|
|
101
|
+
await db.execute(`INSERT INTO user_sessions (token_hash,user_id,created_at,expires_at)
|
|
102
|
+
VALUES (?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3) + INTERVAL 30 DAY)`, [tokenHash(token), userId]);
|
|
103
|
+
await db.commit();
|
|
104
|
+
return { token, user: { id: userId, email }, expiresInSeconds: 30 * 86400 };
|
|
105
|
+
} catch (failure) { await db.rollback(); throw failure; }
|
|
106
|
+
finally { db.release(); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function current(token) {
|
|
110
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw error(401, 'Unauthorized');
|
|
111
|
+
const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,s.expires_at FROM user_sessions s
|
|
112
|
+
JOIN users u ON u.id=s.user_id JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
|
|
113
|
+
WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(3) AND u.status='active'`, [tokenHash(token)]);
|
|
114
|
+
if (!rows.length) throw error(401, 'Unauthorized');
|
|
115
|
+
return { id: rows[0].id, email: rows[0].email, expiresAt: isoTime(rows[0].expires_at) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function logout(token) {
|
|
119
|
+
await current(token);
|
|
120
|
+
await pool.execute('UPDATE user_sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE token_hash=? AND revoked_at IS NULL', [tokenHash(token)]);
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, logout };
|
|
125
|
+
}
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
-
import { startBackground, processAlive } from '../background-process.mjs';
|
|
6
4
|
import { promisify } from 'node:util';
|
|
7
5
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
6
|
import { hostname } from 'node:os';
|
|
10
7
|
import QRCode from 'qrcode';
|
|
11
8
|
import { credentialStore } from '../platform/windows/credential-store.mjs';
|
|
12
9
|
import { readRecord, writeRecord } from '../service-lifecycle.mjs';
|
|
13
10
|
import { serviceHealth } from '../service-health.mjs';
|
|
14
|
-
import {
|
|
11
|
+
import { controlRequest } from '../service-control.mjs';
|
|
15
12
|
import { connectBridgeRelay } from './connector.mjs';
|
|
16
13
|
import { validProbeIceServers } from './p2p-probe.mjs';
|
|
17
14
|
import { relayAddress, validSecret } from './wire.mjs';
|
|
@@ -19,8 +16,6 @@ import { saveLanAuthorizations } from './lan-authorizations.mjs';
|
|
|
19
16
|
import { lanNetworks } from '../platform/windows/network-info.mjs';
|
|
20
17
|
|
|
21
18
|
const exec = promisify(execFile);
|
|
22
|
-
const script = fileURLToPath(import.meta.url);
|
|
23
|
-
const recordPath = root => path.join(root, 'self-relay-session.json');
|
|
24
19
|
const officialConfig = { url: 'wss://api.uulife.site/relay' };
|
|
25
20
|
|
|
26
21
|
async function loadConfig(stateRoot) {
|
|
@@ -59,54 +54,16 @@ async function registrationKey(config) {
|
|
|
59
54
|
return hostKey;
|
|
60
55
|
}
|
|
61
56
|
export async function selfRelay(command, stateRoot) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const deadline = Date.now() + 10000;
|
|
73
|
-
while (current && processAlive(previous.pid)) {
|
|
74
|
-
if (Date.now() >= deadline) throw new Error('私有中继未在 10 秒内退出,未继续恢复服务。');
|
|
75
|
-
await delay(100);
|
|
76
|
-
}
|
|
77
|
-
return { stopped: true, displayText: '私有中继连接器已停止;Bridge 和 Codex 保持运行。' };
|
|
78
|
-
}
|
|
79
|
-
if (current) {
|
|
80
|
-
if (command === 'relay-revoke') return controlRequest(previous, 'POST', 'revoke');
|
|
81
|
-
if (command === 'relay-pair' || (current.relayConnected && !current.paired && !current.qrPath)) return controlRequest(previous, 'POST', 'pair');
|
|
82
|
-
if (command === 'relay-start' && current.relayConnected) return controlRequest(previous, 'POST', 'refresh-lan');
|
|
83
|
-
return current;
|
|
84
|
-
}
|
|
85
|
-
if (command === 'relay-revoke') throw new Error('请先启动连接器,再撤销移动端授权。');
|
|
86
|
-
fs.mkdirSync(stateRoot, { recursive: true });
|
|
87
|
-
const instanceId = randomUUID();
|
|
88
|
-
const log = path.join(stateRoot, 'self-relay.log');
|
|
89
|
-
const readyPath = path.join(stateRoot, `relay-ready-${instanceId}.json`);
|
|
90
|
-
const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, instanceId,
|
|
91
|
-
command === 'relay-pair' ? 'pair' : ''], process.env, [log, log]);
|
|
92
|
-
try {
|
|
93
|
-
const deadline = Date.now() + 25000;
|
|
94
|
-
while (true) {
|
|
95
|
-
const result = readRecord(readyPath);
|
|
96
|
-
if (result) {
|
|
97
|
-
if (result.pid !== pid || result.instanceId !== instanceId) throw new Error('私有中继启动实例不匹配。');
|
|
98
|
-
if (result.error) throw new Error(result.error);
|
|
99
|
-
return result.status;
|
|
100
|
-
}
|
|
101
|
-
if (!processAlive(pid)) throw new Error('私有中继启动失败,请查看 self-relay.log。');
|
|
102
|
-
if (Date.now() >= deadline) throw new Error('私有中继启动超时。');
|
|
103
|
-
await delay(100);
|
|
104
|
-
}
|
|
105
|
-
} catch (error) { if (processAlive(pid)) process.kill(pid); throw error; }
|
|
106
|
-
finally { fs.rmSync(readyPath, { force: true }); }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function worker(stateRoot, instanceId, refresh = false) {
|
|
57
|
+
const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
58
|
+
if (!session) {
|
|
59
|
+
if (command === 'relay-status') return { running: false, displayText: 'Bridge 未运行。' };
|
|
60
|
+
if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
|
|
61
|
+
throw new Error('Bridge 尚未运行,请先执行 relax start。');
|
|
62
|
+
}
|
|
63
|
+
return controlRequest(session, 'POST', command);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function createRelayRuntime(stateRoot) {
|
|
110
67
|
const config = await loadConfig(stateRoot);
|
|
111
68
|
const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
112
69
|
let health = await serviceHealth(session());
|
|
@@ -132,8 +89,7 @@ async function worker(stateRoot, instanceId, refresh = false) {
|
|
|
132
89
|
try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
|
|
133
90
|
catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
|
|
134
91
|
}
|
|
135
|
-
|
|
136
|
-
let connector, control, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
|
|
92
|
+
let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
|
|
137
93
|
let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
|
|
138
94
|
const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
|
|
139
95
|
async function currentLanCandidates() {
|
|
@@ -161,8 +117,6 @@ async function worker(stateRoot, instanceId, refresh = false) {
|
|
|
161
117
|
if (closing) return;
|
|
162
118
|
closing = true; clearTimeout(retryTimer);
|
|
163
119
|
connector?.events.removeAllListeners(); connector?.close();
|
|
164
|
-
if (readRecord(recordPath(stateRoot))?.instanceId === instanceId) fs.rmSync(recordPath(stateRoot), { force: true });
|
|
165
|
-
await control?.close();
|
|
166
120
|
}
|
|
167
121
|
async function saveInvitation(message) {
|
|
168
122
|
if (!message.code) return;
|
|
@@ -245,25 +199,39 @@ async function worker(stateRoot, instanceId, refresh = false) {
|
|
|
245
199
|
audit('lan-candidates-updated', { count: candidates.length });
|
|
246
200
|
return { ...status(), lanCandidateCount: candidates.length };
|
|
247
201
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
202
|
+
const inspect = async () => {
|
|
203
|
+
if (online) {
|
|
204
|
+
const remote = await connector.command('status'); paired = remote.paired;
|
|
205
|
+
await refreshHealth();
|
|
206
|
+
}
|
|
207
|
+
return status();
|
|
208
|
+
};
|
|
209
|
+
try { await connect(); return { status, inspect, pair, revoke, refreshLan, shutdown }; }
|
|
210
|
+
catch (error) { await shutdown(); throw error; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createRelayManager(stateRoot) {
|
|
214
|
+
let current;
|
|
215
|
+
let operation = Promise.resolve();
|
|
216
|
+
const run = command => {
|
|
217
|
+
const next = operation.then(async () => {
|
|
218
|
+
if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
|
|
219
|
+
if (command === 'relay-stop') {
|
|
220
|
+
await current?.shutdown(); current = undefined;
|
|
221
|
+
return { stopped: true, displayText: '中继连接已停止;Bridge 和 Codex 保持运行。' };
|
|
222
|
+
}
|
|
223
|
+
if (!current) {
|
|
224
|
+
if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
|
|
225
|
+
current = await createRelayRuntime(stateRoot);
|
|
226
|
+
return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
|
|
227
|
+
}
|
|
228
|
+
if (command === 'relay-revoke') return current.revoke();
|
|
229
|
+
if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
|
|
230
|
+
if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
|
|
231
|
+
return current.status();
|
|
232
|
+
});
|
|
233
|
+
operation = next.catch(() => {});
|
|
234
|
+
return next;
|
|
235
|
+
};
|
|
236
|
+
return { run, close: () => run('relay-stop'), status: () => current?.status() || { running: false } };
|
|
237
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import mysql from 'mysql2/promise';
|
|
5
|
+
import { deviceStore } from './device-store.mjs';
|
|
6
|
+
import { relayAdminState } from './admin-state.mjs';
|
|
7
|
+
|
|
8
|
+
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
9
|
+
const isoTime = value => value.replace(' ', 'T') + 'Z';
|
|
10
|
+
|
|
11
|
+
export async function openRelayMysql(configFile, initialMaxRelaySessions) {
|
|
12
|
+
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
13
|
+
if (!config.database || !config.user || !config.password || !(config.socketPath || config.host)) {
|
|
14
|
+
throw new Error('Incomplete relay MySQL configuration');
|
|
15
|
+
}
|
|
16
|
+
const pool = mysql.createPool({ ...config, waitForConnections: true, connectionLimit: 4,
|
|
17
|
+
namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
|
|
18
|
+
try {
|
|
19
|
+
const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
|
|
20
|
+
const knownVersions = ['001_relay_core', '002_email_accounts'];
|
|
21
|
+
if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
|
|
22
|
+
for (const row of versions) {
|
|
23
|
+
if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
|
|
24
|
+
const source = fs.readFileSync(fileURLToPath(new URL(`../../database/${row.version}.sql`, import.meta.url)));
|
|
25
|
+
if (row.sha256 !== createHash('sha256').update(source).digest('hex')) throw new Error(`Unexpected relay database schema: ${row.version}`);
|
|
26
|
+
}
|
|
27
|
+
const [computers] = await pool.query('SELECT id, host_hash, status, revision, created_at, updated_at FROM computers');
|
|
28
|
+
const [clients] = await pool.query('SELECT computer_id, client_id, credential_hash, created_at, updated_at FROM mobile_authorizations ORDER BY created_at');
|
|
29
|
+
const [official] = await pool.query('SELECT computer_id, environment_id, name, hostname, updated_at FROM official_computers');
|
|
30
|
+
const records = new Map(computers.map(row => [row.id, { id: row.id, hostHash: row.host_hash, status: row.status,
|
|
31
|
+
revision: Number(row.revision), createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at), clients: [] }]));
|
|
32
|
+
for (const row of clients) {
|
|
33
|
+
const record = records.get(row.computer_id);
|
|
34
|
+
if (!record) throw new Error('Orphan mobile authorization');
|
|
35
|
+
record.clients.push({ id: row.client_id, hash: row.credential_hash,
|
|
36
|
+
createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at) });
|
|
37
|
+
}
|
|
38
|
+
for (const row of official) {
|
|
39
|
+
const record = records.get(row.computer_id);
|
|
40
|
+
if (!record) throw new Error('Orphan official computer');
|
|
41
|
+
record.officialComputer = { environmentId: row.environment_id, name: row.name,
|
|
42
|
+
hostname: row.hostname, updatedAt: isoTime(row.updated_at) };
|
|
43
|
+
}
|
|
44
|
+
const validator = deviceStore();
|
|
45
|
+
for (const record of records.values()) validator.save(record);
|
|
46
|
+
|
|
47
|
+
const [settings] = await pool.query('SELECT max_relay_sessions FROM relay_settings WHERE id = 1');
|
|
48
|
+
if (settings.length !== 1) throw new Error('Missing relay settings');
|
|
49
|
+
const [rows] = await pool.query('SELECT id, title, message, status, created_at, updated_at, published_at FROM announcements ORDER BY created_at DESC');
|
|
50
|
+
const announcements = rows.map(row => ({ id: row.id, title: row.title, message: row.message, status: row.status,
|
|
51
|
+
createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at),
|
|
52
|
+
publishedAt: row.published_at ? isoTime(row.published_at) : null }));
|
|
53
|
+
const active = announcements.find(item => item.status === 'active');
|
|
54
|
+
const initial = { version: 3, maxRelaySessions: settings[0].max_relay_sessions, announcements,
|
|
55
|
+
announcement: active ? { id: active.id, title: active.title, message: active.message, publishedAt: active.publishedAt } : null };
|
|
56
|
+
const admin = relayAdminState(undefined, initialMaxRelaySessions, { initial, persist: async next => {
|
|
57
|
+
const connection = await pool.getConnection();
|
|
58
|
+
try {
|
|
59
|
+
await connection.beginTransaction();
|
|
60
|
+
await connection.execute('UPDATE relay_settings SET max_relay_sessions = ?, updated_at = UTC_TIMESTAMP(3) WHERE id = 1', [next.maxRelaySessions]);
|
|
61
|
+
await connection.query('DELETE FROM announcements');
|
|
62
|
+
for (const item of next.announcements) await connection.execute(
|
|
63
|
+
'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
|
|
64
|
+
[item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
|
|
65
|
+
item.publishedAt ? sqlTime(item.publishedAt) : null]);
|
|
66
|
+
await connection.commit();
|
|
67
|
+
} catch (error) { await connection.rollback(); throw error; }
|
|
68
|
+
finally { connection.release(); }
|
|
69
|
+
} });
|
|
70
|
+
return {
|
|
71
|
+
pool,
|
|
72
|
+
schemaVersions: versions.map(row => row.version),
|
|
73
|
+
devices: {
|
|
74
|
+
entries: () => [...records.values()],
|
|
75
|
+
async save(record) {
|
|
76
|
+
validator.save(record);
|
|
77
|
+
const connection = await pool.getConnection();
|
|
78
|
+
try {
|
|
79
|
+
await connection.beginTransaction();
|
|
80
|
+
await connection.execute(`INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at)
|
|
81
|
+
VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE host_hash=VALUES(host_hash),status=VALUES(status),
|
|
82
|
+
revision=VALUES(revision),updated_at=VALUES(updated_at)`,
|
|
83
|
+
[record.id, record.hostHash, record.status, record.revision, sqlTime(record.createdAt), sqlTime(record.updatedAt)]);
|
|
84
|
+
await connection.execute('DELETE FROM mobile_authorizations WHERE computer_id = ?', [record.id]);
|
|
85
|
+
for (const client of record.clients) await connection.execute(
|
|
86
|
+
'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at) VALUES (?,?,?,?,?)',
|
|
87
|
+
[record.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt)]);
|
|
88
|
+
if (record.officialComputer) {
|
|
89
|
+
const item = record.officialComputer;
|
|
90
|
+
await connection.execute(`INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at)
|
|
91
|
+
VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE environment_id=VALUES(environment_id),name=VALUES(name),
|
|
92
|
+
hostname=VALUES(hostname),updated_at=VALUES(updated_at)`,
|
|
93
|
+
[record.id, item.environmentId, item.name, item.hostname, sqlTime(item.updatedAt)]);
|
|
94
|
+
}
|
|
95
|
+
await connection.commit(); records.set(record.id, record);
|
|
96
|
+
} catch (error) { await connection.rollback(); throw error; }
|
|
97
|
+
finally { connection.release(); }
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
admin,
|
|
101
|
+
close: () => pool.end(),
|
|
102
|
+
};
|
|
103
|
+
} catch (error) { await pool.end(); throw error; }
|
|
104
|
+
}
|