@walkhi/code-relax 0.1.0-beta.51 → 0.1.0-beta.59
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/set-relay-admin-password.mjs +11 -0
- package/dist/build-platform.json +1 -1
- package/dist/shared/release-version.cjs +31 -0
- package/dist/src/dots-room-client.mjs +2 -0
- package/dist/src/file-upload-session.mjs +18 -2
- package/dist/src/lan-socket-server.mjs +81 -9
- package/dist/src/platform/desktop-project-store.mjs +20 -9
- package/dist/src/self-relay/admin-auth.mjs +59 -0
- package/dist/src/self-relay/admin-login-page.mjs +7 -0
- package/dist/src/self-relay/admin-page.mjs +15 -8
- package/dist/src/self-relay/admin-server.mjs +49 -18
- package/dist/src/self-relay/admin-style.mjs +44 -0
- package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
- package/dist/src/self-relay/admin-workstations.mjs +143 -0
- package/dist/src/self-relay/connection-reporting.mjs +38 -0
- package/dist/src/self-relay/connector.mjs +8 -3
- package/dist/src/self-relay/lifecycle.mjs +11 -0
- package/dist/src/self-relay/phone-connections.mjs +55 -0
- package/dist/src/self-relay/rtc-file-bridge.mjs +8 -3
- package/dist/src/self-relay/server.mjs +63 -3
- package/dist/src/server.mjs +18 -5
- package/dist/src/service-update.mjs +6 -3
- package/dist/src/shared-app-server.mjs +17 -0
- package/package.json +4 -4
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { passwordRecord } from '../src/self-relay/admin-auth.mjs';
|
|
4
|
+
|
|
5
|
+
// Run as the management service user (or root and then chown root:codexrelay).
|
|
6
|
+
// The plaintext is printed once for handoff; only its salted scrypt hash is saved.
|
|
7
|
+
const file = process.argv[2] || '/etc/codex-relay/admin-password.json';
|
|
8
|
+
const password = randomBytes(18).toString('base64url');
|
|
9
|
+
const record = await passwordRecord(password);
|
|
10
|
+
fs.writeFileSync(file, JSON.stringify(record) + '\n', { mode: 0o640, flag: 'wx' });
|
|
11
|
+
console.log(password);
|
package/dist/build-platform.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"layout":"npm-platform-packages","version":"0.1.0-beta.
|
|
1
|
+
{"layout":"npm-platform-packages","version":"0.1.0-beta.59"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
4
|
+
else root.CodeRelaxReleaseVersion = api;
|
|
5
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
6
|
+
function parse(value) {
|
|
7
|
+
if (typeof value !== 'string') return null;
|
|
8
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
|
|
9
|
+
if (!match) return null;
|
|
10
|
+
const core = match.slice(1, 4).map(Number), pre = match[4]?.split('.') || [];
|
|
11
|
+
if (core.some(part => !Number.isSafeInteger(part)) || pre.some(part => /^\d+$/.test(part) && (part.length > 1 && part[0] === '0' || !Number.isSafeInteger(Number(part))))) return null;
|
|
12
|
+
return { core, pre };
|
|
13
|
+
}
|
|
14
|
+
function compare(a, b) {
|
|
15
|
+
const left = parse(a), right = parse(b);
|
|
16
|
+
if (!left || !right) return null;
|
|
17
|
+
for (let i = 0; i < 3; i++) if (left.core[i] !== right.core[i]) return left.core[i] > right.core[i] ? 1 : -1;
|
|
18
|
+
if (!left.pre.length || !right.pre.length) return left.pre.length === right.pre.length ? 0 : left.pre.length ? -1 : 1;
|
|
19
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
|
|
20
|
+
const x = left.pre[i], y = right.pre[i];
|
|
21
|
+
if (x === y) continue;
|
|
22
|
+
if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
|
|
23
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
|
24
|
+
if (nx && ny) return Number(x) > Number(y) ? 1 : -1;
|
|
25
|
+
if (nx !== ny) return nx ? -1 : 1;
|
|
26
|
+
return x > y ? 1 : -1;
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
return { compare };
|
|
31
|
+
});
|
|
@@ -77,6 +77,8 @@ export class DotsRoomClient extends CelsiusClient {
|
|
|
77
77
|
while ((end = text.indexOf('\n\n')) >= 0) {
|
|
78
78
|
const frame = text.slice(0, end); text = text.slice(end + 2);
|
|
79
79
|
const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
80
|
+
// Desktop's ChatGPT SSE treats [DONE] as completion, not JSON.
|
|
81
|
+
if (data.trim() === '[DONE]') { finish(null, null); return; }
|
|
80
82
|
if (data) try { onEvent(JSON.parse(data)); clearTimeout(timer); } catch (error) { finish(error); return; }
|
|
81
83
|
}
|
|
82
84
|
bytes = Buffer.byteLength(text);
|
|
@@ -34,6 +34,8 @@ async function uniqueDestination(directory, name, partial) {
|
|
|
34
34
|
|
|
35
35
|
export function createFileUploadSession(root) {
|
|
36
36
|
let upload;
|
|
37
|
+
let committed = false;
|
|
38
|
+
const files = new Map();
|
|
37
39
|
const completed = new Map();
|
|
38
40
|
let completedBytes = 0;
|
|
39
41
|
const directory = path.join(root, 'incoming');
|
|
@@ -46,11 +48,12 @@ export function createFileUploadSession(root) {
|
|
|
46
48
|
};
|
|
47
49
|
const clear = async () => {
|
|
48
50
|
await clearActive();
|
|
51
|
+
if (committed) return;
|
|
49
52
|
for (const file of completed.values()) {
|
|
50
53
|
await fs.unlink(file.path).catch(() => {});
|
|
51
54
|
if (file.preview) await fs.unlink(file.preview.path).catch(() => {});
|
|
52
55
|
}
|
|
53
|
-
completed.clear(); completedBytes = 0;
|
|
56
|
+
completed.clear(); files.clear(); completedBytes = 0;
|
|
54
57
|
};
|
|
55
58
|
return {
|
|
56
59
|
async receive(message) {
|
|
@@ -58,19 +61,29 @@ export function createFileUploadSession(root) {
|
|
|
58
61
|
if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
|
|
59
62
|
if (message.type === 'commit') {
|
|
60
63
|
if (upload) throw new Error('文件尚未上传完成');
|
|
61
|
-
|
|
64
|
+
committed = true;
|
|
62
65
|
return { type: 'committed' };
|
|
63
66
|
}
|
|
64
67
|
if (message.type === 'remove') {
|
|
68
|
+
if (committed) throw new Error('文件批次已提交');
|
|
65
69
|
if (upload) throw new Error('请等待当前文件上传完成');
|
|
66
70
|
const file = completed.get(message.id);
|
|
67
71
|
if (!file) throw new Error('文件上传会话已失效');
|
|
68
72
|
await fs.unlink(file.path);
|
|
69
73
|
if (file.preview) await fs.unlink(file.preview.path);
|
|
70
74
|
completed.delete(message.id); completedBytes -= file.size;
|
|
75
|
+
for (const [key, value] of files) if (value.id === message.id || value.previewOf === message.id) files.delete(key);
|
|
71
76
|
return { type: 'removed', id: message.id };
|
|
72
77
|
}
|
|
73
78
|
if (message.type === 'start') {
|
|
79
|
+
if (typeof message.fileKey !== 'string' || !/^[0-9]+(?:-preview)?$/.test(message.fileKey)) throw new Error('文件批次编号无效');
|
|
80
|
+
const previous = files.get(message.fileKey);
|
|
81
|
+
if (previous) {
|
|
82
|
+
if (previous.originalName !== message.name || previous.size !== message.size ||
|
|
83
|
+
previous.expected !== message.sha256?.toLowerCase() || previous.previewOf !== message.previewOf) throw new Error('续传文件身份不一致');
|
|
84
|
+
return previous.result || { type: 'started', id: previous.id, offset: previous.offset, chunkBytes: FILE_CHUNK_BYTES };
|
|
85
|
+
}
|
|
86
|
+
if (committed) throw new Error('文件批次已提交');
|
|
74
87
|
if (upload) throw new Error('已有文件正在上传');
|
|
75
88
|
if (!validName(message.name)) throw new Error('文件名无效');
|
|
76
89
|
if (!Number.isSafeInteger(message.size) || message.size < 1 || message.size > MAX_FILE_BYTES) {
|
|
@@ -87,7 +100,9 @@ export function createFileUploadSession(root) {
|
|
|
87
100
|
const partial = path.join(directory, `.upload-${id}`);
|
|
88
101
|
const handle = await fs.open(partial, 'wx');
|
|
89
102
|
upload = { id, partial, handle, name: parent ? `${id}.preview.jpg` : message.name, size: message.size, parent,
|
|
103
|
+
originalName: message.name, previewOf: message.previewOf,
|
|
90
104
|
expected: message.sha256.toLowerCase(), hash: crypto.createHash('sha256'), offset: 0 };
|
|
105
|
+
files.set(message.fileKey, upload);
|
|
91
106
|
return { type: 'started', id, offset: 0, chunkBytes: FILE_CHUNK_BYTES };
|
|
92
107
|
}
|
|
93
108
|
const current = upload;
|
|
@@ -119,6 +134,7 @@ export function createFileUploadSession(root) {
|
|
|
119
134
|
try { saved = await uniqueDestination(directory, current.name, current.partial); }
|
|
120
135
|
catch (error) { await fs.unlink(current.partial).catch(() => {}); throw error; }
|
|
121
136
|
const result = { type: 'completed', id: current.id, ...saved, size: current.size, sha256: current.expected };
|
|
137
|
+
current.result = result;
|
|
122
138
|
if (current.parent) current.parent.preview = result;
|
|
123
139
|
else { completed.set(current.id, result); completedBytes += current.size; }
|
|
124
140
|
return result;
|
|
@@ -7,12 +7,21 @@ import { FILE_READ_LIMIT } from './file-transfer-range.mjs';
|
|
|
7
7
|
|
|
8
8
|
// One authenticated connection carries requests and explicitly owned subscriptions.
|
|
9
9
|
// The dispatcher is shared business routing, not an HTTP loopback proxy.
|
|
10
|
-
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, onDeviceConnected,
|
|
10
|
+
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, onDeviceConnected,
|
|
11
|
+
onPhoneConnected, onPhoneDisconnected, uploadsRoot }) {
|
|
11
12
|
// Allow four individually valid images to reach the aggregate-size validator,
|
|
12
13
|
// so an oversized composition returns an error instead of dropping the connection.
|
|
13
14
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
|
|
14
15
|
const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
15
16
|
const activeUploads = new Map();
|
|
17
|
+
const uploadSessions = new Map();
|
|
18
|
+
let closing = false;
|
|
19
|
+
const expireUpload = async (id, entry) => {
|
|
20
|
+
if (uploadSessions.get(id) !== entry) return;
|
|
21
|
+
uploadSessions.delete(id);
|
|
22
|
+
clearTimeout(entry.timer);
|
|
23
|
+
await entry.session.close();
|
|
24
|
+
};
|
|
16
25
|
const deviceConnections = new Map();
|
|
17
26
|
const stopWatching = onDeviceAuthorizationsChanged?.(() => {
|
|
18
27
|
for (const connection of deviceConnections.values()) {
|
|
@@ -39,7 +48,9 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
39
48
|
target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
|
|
40
49
|
});
|
|
41
50
|
uploadWss.on('connection', ws => {
|
|
42
|
-
let authenticated = false, stopped = false, identity = '', upload, purpose, chunk;
|
|
51
|
+
let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId;
|
|
52
|
+
let release, discardUpload = false;
|
|
53
|
+
const released = new Promise(resolve => { release = resolve; });
|
|
43
54
|
let queued = 0;
|
|
44
55
|
let pending = Promise.resolve();
|
|
45
56
|
const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
|
|
@@ -51,8 +62,16 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
51
62
|
deviceConnections.delete(ws);
|
|
52
63
|
clearTimeout(authTimer);
|
|
53
64
|
void pending.finally(async () => {
|
|
54
|
-
await upload?.close();
|
|
55
65
|
activeUploads.delete(ws);
|
|
66
|
+
if (uploadEntry) {
|
|
67
|
+
uploadEntry.ws = null;
|
|
68
|
+
if (discardUpload || closing) await expireUpload(uploadId, uploadEntry);
|
|
69
|
+
else {
|
|
70
|
+
uploadEntry.timer = setTimeout(() => { void expireUpload(uploadId, uploadEntry); }, 5 * 60 * 1000);
|
|
71
|
+
uploadEntry.timer.unref();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
release();
|
|
56
75
|
});
|
|
57
76
|
};
|
|
58
77
|
ws.on('close', stop);
|
|
@@ -76,21 +95,54 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
76
95
|
authorizeDevice(message.clientId, message.credential) === true) identity = `device:${message.clientId}`;
|
|
77
96
|
else if (token && message?.type === 'auth' && typeof message.token === 'string') {
|
|
78
97
|
const supplied = Buffer.from(message.token), expected = Buffer.from(token || '');
|
|
79
|
-
if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected))
|
|
98
|
+
if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) {
|
|
99
|
+
identity = typeof message.relayClientId === 'string' && message.relayClientId.length <= 200
|
|
100
|
+
? `relay:${message.relayClientId}` : 'local-token';
|
|
101
|
+
}
|
|
80
102
|
}
|
|
103
|
+
if (identity && purpose === 'upload') {
|
|
104
|
+
uploadId = message.uploadSessionId;
|
|
105
|
+
if (typeof uploadId !== 'string' || !/^[a-f0-9-]{36}$/.test(uploadId)) throw new Error('上传批次编号无效');
|
|
106
|
+
const entry = uploadSessions.get(uploadId);
|
|
107
|
+
if (entry) {
|
|
108
|
+
if (entry.identity !== identity) throw new Error('上传批次身份不一致');
|
|
109
|
+
if (entry.ws) { entry.ws.terminate(); await entry.released; }
|
|
110
|
+
if (uploadSessions.get(uploadId) !== entry) throw new Error('上传批次已失效,无法确认原批次状态');
|
|
111
|
+
} else if (message.resumeUpload) throw new Error('上传断点已过期或工作站已重启,请重新发送');
|
|
112
|
+
}
|
|
113
|
+
// Taking over a connection can await an in-flight disk write. The new
|
|
114
|
+
// socket and its authorization must still be live when that finishes.
|
|
115
|
+
if (stopped || closing || ws.readyState !== WebSocket.OPEN) return;
|
|
116
|
+
if (message.type === 'device-auth' && !authorizeDevice(message.clientId, message.credential)) throw new Error('设备授权已失效');
|
|
81
117
|
const same = [...activeUploads.values()].filter(item => item.identity === identity && item.purpose === purpose);
|
|
82
118
|
const uploads = [...activeUploads.values()].filter(item => item.purpose === 'upload').length;
|
|
83
119
|
if (!identity || same.length >= (purpose === 'upload' ? 1 : 3) || activeUploads.size >= 8 ||
|
|
84
120
|
(purpose === 'upload' && uploads >= 2)) throw new Error('文件身份无效或文件任务过多');
|
|
85
121
|
if (message.type === 'device-auth') deviceConnections.set(ws, {
|
|
86
122
|
clientId: message.clientId, credential: message.credential,
|
|
87
|
-
revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
|
|
123
|
+
revoke() { discardUpload = true; stop(); ws.close(4401, 'Device authorization revoked'); },
|
|
88
124
|
});
|
|
89
125
|
activeUploads.set(ws, { identity, purpose }); authenticated = true; clearTimeout(authTimer);
|
|
90
126
|
if (message.type === 'device-auth') onDeviceConnected?.(message.clientId, message);
|
|
91
|
-
if (purpose === 'upload')
|
|
92
|
-
|
|
93
|
-
|
|
127
|
+
if (purpose === 'upload') {
|
|
128
|
+
uploadEntry = uploadSessions.get(uploadId);
|
|
129
|
+
if (!uploadEntry) {
|
|
130
|
+
if ([...uploadSessions.values()].filter(entry => !entry.committed).length >= 2) throw new Error('待完成上传批次过多,请稍后再试');
|
|
131
|
+
if (uploadSessions.size >= 32) {
|
|
132
|
+
const oldest = [...uploadSessions].find(([, entry]) => entry.committed && !entry.ws);
|
|
133
|
+
if (!oldest) throw new Error('上传批次过多,请稍后再试');
|
|
134
|
+
await expireUpload(...oldest);
|
|
135
|
+
}
|
|
136
|
+
if (stopped || closing || ws.readyState !== WebSocket.OPEN) return;
|
|
137
|
+
uploadEntry = { identity, session: createFileUploadSession(uploadsRoot) };
|
|
138
|
+
uploadSessions.set(uploadId, uploadEntry);
|
|
139
|
+
}
|
|
140
|
+
clearTimeout(uploadEntry.timer);
|
|
141
|
+
Object.assign(uploadEntry, { ws, released });
|
|
142
|
+
upload = uploadEntry.session;
|
|
143
|
+
}
|
|
144
|
+
reply({ type: 'ready', version: 3, purpose, maxDownloadBytes: FILE_READ_LIMIT, maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
|
|
145
|
+
maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES, uploadSessionId: uploadId });
|
|
94
146
|
return;
|
|
95
147
|
}
|
|
96
148
|
if (purpose === 'download') {
|
|
@@ -112,12 +164,19 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
112
164
|
chunk = message; return;
|
|
113
165
|
}
|
|
114
166
|
reply(await upload.receive(message));
|
|
115
|
-
|
|
167
|
+
if (message.type === 'commit') uploadEntry.committed = true;
|
|
168
|
+
if (message.type === 'cancel') {
|
|
169
|
+
await expireUpload(uploadId, uploadEntry);
|
|
170
|
+
uploadEntry = null;
|
|
171
|
+
}
|
|
172
|
+
}).catch(error => { reply({ type: 'error', error: error.message }); discardUpload = true; stop(); ws.close(1008, 'File request rejected'); })
|
|
116
173
|
.finally(() => { queued--; });
|
|
117
174
|
});
|
|
118
175
|
});
|
|
119
176
|
wss.on('connection', ws => {
|
|
120
177
|
let authenticated = false, alive = true, stopped = false;
|
|
178
|
+
let phone, reported = false;
|
|
179
|
+
const connectionId = crypto.randomUUID();
|
|
121
180
|
const active = new Map();
|
|
122
181
|
const send = value => {
|
|
123
182
|
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -136,6 +195,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
136
195
|
stopped = true;
|
|
137
196
|
deviceConnections.delete(ws);
|
|
138
197
|
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
198
|
+
if (reported) onPhoneDisconnected?.(connectionId);
|
|
139
199
|
for (const reply of active.values()) reply.end();
|
|
140
200
|
active.clear();
|
|
141
201
|
};
|
|
@@ -158,6 +218,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
158
218
|
if (!accepted) {
|
|
159
219
|
ws.close(1008, 'Authentication failed'); return;
|
|
160
220
|
}
|
|
221
|
+
if (message.type === 'device-auth') phone = { clientId: message.clientId, mode: 'lan' };
|
|
222
|
+
else if (typeof message.relayClientId === 'string' && /^[\w-]{16,128}$/.test(message.relayClientId)) {
|
|
223
|
+
phone = { clientId: message.relayClientId, mode: 'self-relay' };
|
|
224
|
+
}
|
|
161
225
|
if (message.type === 'device-auth') onDeviceConnected?.(message.clientId, message);
|
|
162
226
|
if (message.type === 'device-auth') deviceConnections.set(ws, {
|
|
163
227
|
clientId: message.clientId, credential: message.credential,
|
|
@@ -186,6 +250,12 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
186
250
|
(message.body !== undefined && (!message.body || typeof message.body !== 'object' || Array.isArray(message.body)))) {
|
|
187
251
|
send({ type: 'response', id, status: 400, body: { error: '无效的连接请求' } }); return;
|
|
188
252
|
}
|
|
253
|
+
// Health probes, management and file sockets are not chat connections.
|
|
254
|
+
// Three message sources share this catalog/events entry point.
|
|
255
|
+
if (phone && !reported && /^\/api\/(?:threads(?:\?|$)|thread-events(?:\?|$)|threads\/[^/?]+\/(?:events|dots-activity-events)(?:\?|$))/.test(path)) {
|
|
256
|
+
reported = true;
|
|
257
|
+
onPhoneConnected?.(connectionId, phone.clientId, phone.mode);
|
|
258
|
+
}
|
|
189
259
|
const reply = new EventEmitter();
|
|
190
260
|
let waiting, batch = 0;
|
|
191
261
|
const sendPart = (pieces, pageEnd = false) => new Promise((resolve, reject) => {
|
|
@@ -259,8 +329,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
259
329
|
});
|
|
260
330
|
});
|
|
261
331
|
return { close() {
|
|
332
|
+
closing = true;
|
|
262
333
|
stopWatching?.();
|
|
263
334
|
for (const ws of wss.clients) ws.terminate(); wss.close();
|
|
264
335
|
for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
|
|
336
|
+
for (const [id, entry] of uploadSessions) if (!entry.ws) void expireUpload(id, entry);
|
|
265
337
|
} };
|
|
266
338
|
}
|
|
@@ -140,6 +140,11 @@ export async function withDesktopProjectRegistration(options, action) {
|
|
|
140
140
|
const mutate = async (project, operation, saveOperation, removeNative) => {
|
|
141
141
|
const transform = updating ? (state, value, root) => updateProjectInState(state, value, root, operation.previousProject)
|
|
142
142
|
: removing ? removeProjectFromState : registerProjectInState;
|
|
143
|
+
const verify = async () => {
|
|
144
|
+
const finalState = parseState(await fs.readFile(stateFile));
|
|
145
|
+
const verified = transform(finalState, project, codexDataRoot);
|
|
146
|
+
if (JSON.stringify(verified) !== JSON.stringify(finalState)) throw new Error('Desktop 项目状态未能在最终文件中核对,请检查后继续完成操作。');
|
|
147
|
+
};
|
|
143
148
|
let restartNeeded = operation.desktopWasRunning === true;
|
|
144
149
|
let registrationError;
|
|
145
150
|
const current = await host.inspect();
|
|
@@ -181,27 +186,33 @@ export async function withDesktopProjectRegistration(options, action) {
|
|
|
181
186
|
if (removing) await removeNative();
|
|
182
187
|
operation[removing ? 'desktopRemoved' : 'desktopRegistered'] = true;
|
|
183
188
|
await saveOperation();
|
|
189
|
+
// Verify the committed registry while Desktop is still closed. New
|
|
190
|
+
// projects are usable in the kernel without waiting for its UI.
|
|
191
|
+
if (!removing && !updating) await verify();
|
|
184
192
|
} catch (error) { registrationError = error; }
|
|
185
193
|
// Restore the UI even if the registry write failed. This never starts or
|
|
186
194
|
// restarts a kernel and never changes the service's active connections.
|
|
187
195
|
if (restartNeeded) {
|
|
188
196
|
try {
|
|
189
197
|
if (!(await host.inspect()).running) await host.startDesktop(info.desktop, { CODEX_APP_SERVER_WS_URL: serverUrl, CODEX_HOME: codexDataRoot });
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
198
|
+
// Creation completes once registration is verified and the OS has
|
|
199
|
+
// accepted launch. Keep update/removal's existing readiness contract.
|
|
200
|
+
if (removing || updating) {
|
|
201
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
202
|
+
while (!await host.desktopReady()) {
|
|
203
|
+
if (Date.now() >= deadline) throw new Error('Codex Desktop 尚未重新就绪。');
|
|
204
|
+
await wait(500);
|
|
205
|
+
}
|
|
206
|
+
if (await host.sharedServiceUrl() !== serverUrl) throw new Error('Desktop 未接入原工作站内核。');
|
|
194
207
|
}
|
|
195
|
-
if (await host.sharedServiceUrl() !== serverUrl) throw new Error('Desktop 未接入原工作站内核。');
|
|
196
208
|
} catch (error) {
|
|
197
209
|
throw new Error([registrationError?.message, `${removing ? '项目移除状态已记录' : '项目已保留'},重新打开 Codex Desktop 未完成:${error.message}`].filter(Boolean).join(' '));
|
|
198
210
|
}
|
|
199
211
|
}
|
|
200
212
|
if (registrationError) throw registrationError;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return { status: removing ? 'removed' : updating ? 'updated' : 'registered', restarted: restartNeeded };
|
|
213
|
+
if (removing || updating) await verify();
|
|
214
|
+
return { status: removing ? 'removed' : updating ? 'updated' : 'registered',
|
|
215
|
+
...(removing || updating ? { restarted: restartNeeded } : { restartRequested: restartNeeded }) };
|
|
205
216
|
};
|
|
206
217
|
return await action({ register: mutate, remove: mutate });
|
|
207
218
|
} finally { await release(); }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
const derive = promisify(scrypt);
|
|
6
|
+
const COOKIE = '__Host-code-relax-admin';
|
|
7
|
+
const SESSION_MS = 12 * 60 * 60 * 1000;
|
|
8
|
+
const cookie = (value, seconds) => `${COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${seconds}`;
|
|
9
|
+
|
|
10
|
+
export async function passwordRecord(password) {
|
|
11
|
+
if (typeof password !== 'string' || password.length < 10 || Buffer.byteLength(password) > 256) {
|
|
12
|
+
throw new Error('Admin password must have at least 10 characters and at most 256 bytes');
|
|
13
|
+
}
|
|
14
|
+
const salt = randomBytes(16).toString('hex');
|
|
15
|
+
return { salt, hash: (await derive(password, salt, 64)).toString('hex') };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createAdminAuth(passwordFile) {
|
|
19
|
+
const { salt, hash } = JSON.parse(fs.readFileSync(passwordFile, 'utf8'));
|
|
20
|
+
if (!/^[a-f0-9]{32}$/.test(salt) || !/^[a-f0-9]{128}$/.test(hash)) throw new Error('Invalid admin password record');
|
|
21
|
+
const expected = Buffer.from(hash, 'hex');
|
|
22
|
+
const sessions = new Map();
|
|
23
|
+
let windowStart = 0, attempts = 0, verifying = false;
|
|
24
|
+
const token = request => (request.headers.cookie || '').split(';').map(value => value.trim())
|
|
25
|
+
.find(value => value.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
|
|
26
|
+
function purge() {
|
|
27
|
+
for (const [id, expires] of sessions) if (expires <= Date.now()) sessions.delete(id);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
authenticated(request) { purge(); return sessions.has(token(request)); },
|
|
31
|
+
async login(password, request, response) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
if (now - windowStart >= 60000) { windowStart = now; attempts = 0; }
|
|
34
|
+
// This is a single-owner console: bound both guessing and concurrent scrypt memory.
|
|
35
|
+
if (attempts >= 10 || verifying) {
|
|
36
|
+
response.setHeader('Retry-After', '60');
|
|
37
|
+
return { status: 429, error: '尝试过于频繁,请一分钟后重试' };
|
|
38
|
+
}
|
|
39
|
+
attempts++;
|
|
40
|
+
if (typeof password !== 'string' || Buffer.byteLength(password) > 256) return { status: 400, error: '请输入有效密码' };
|
|
41
|
+
verifying = true;
|
|
42
|
+
try {
|
|
43
|
+
const actual = await derive(password, salt, 64);
|
|
44
|
+
if (!timingSafeEqual(actual, expected)) return { status: 401, error: '密码不正确' };
|
|
45
|
+
purge();
|
|
46
|
+
sessions.delete(token(request));
|
|
47
|
+
if (sessions.size >= 32) sessions.delete(sessions.keys().next().value);
|
|
48
|
+
const id = randomBytes(32).toString('base64url');
|
|
49
|
+
sessions.set(id, Date.now() + SESSION_MS);
|
|
50
|
+
response.setHeader('Set-Cookie', cookie(id, SESSION_MS / 1000));
|
|
51
|
+
return { status: 200 };
|
|
52
|
+
} finally { verifying = false; }
|
|
53
|
+
},
|
|
54
|
+
logout(request, response) {
|
|
55
|
+
sessions.delete(token(request));
|
|
56
|
+
response.setHeader('Set-Cookie', cookie('', 0));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { adminUiTokens, adminFooterStyles, adminAuthStyles } from './admin-style.mjs';
|
|
2
|
+
|
|
3
|
+
export function adminLoginPage() {
|
|
4
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录 · Code Relax 管理</title><style>${adminUiTokens}${adminFooterStyles}${adminAuthStyles}</style></head>
|
|
5
|
+
<body class="admin-login"><main><form id="login" class="login-card"><h1>Code Relax 管理</h1><p>输入管理密码,进入后台。</p><label for="password">管理密码</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="256" autofocus><button id="submit" type="submit">登录</button><p id="result" role="alert" aria-live="polite"></p></form></main><footer class="admin-footer">京ICP备2026027643号-2</footer>
|
|
6
|
+
<script>const form=document.getElementById('login'),button=document.getElementById('submit'),result=document.getElementById('result');form.addEventListener('submit',async event=>{event.preventDefault();if(button.disabled)return;button.disabled=true;result.textContent='正在登录…';try{const response=await fetch('/admin/login',{method:'POST',headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify({password:form.password.value})});const body=await response.json();if(!response.ok)throw new Error(body.error||'登录失败');form.reset();location.replace('/')}catch(error){result.textContent=error.message}finally{button.disabled=false}});</script></body></html>`;
|
|
7
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { adminUiTokens, adminFooterStyles } from './admin-style.mjs';
|
|
1
|
+
import { adminUiTokens, adminFooterStyles, adminAuthStyles, adminWorkstationStyles } from './admin-style.mjs';
|
|
2
|
+
import { adminWorkstationsMarkup, adminWorkstationsScript } from './admin-workstations-page.mjs';
|
|
2
3
|
|
|
3
|
-
export function adminPage(
|
|
4
|
+
export function adminPage() {
|
|
4
5
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title><style>${adminUiTokens}${adminFooterStyles}</style>
|
|
5
6
|
<style>@media(max-width:600px){body main{padding:12px 12px 40px}header.page-head{align-items:center;gap:8px;margin-bottom:8px}header.page-head h1{font-size:20px;line-height:1.2;margin:0}main .nav{gap:0;margin-bottom:12px}main .nav button{padding:8px 11px}main .stack,main .grid{gap:10px}main .card{padding:12px}main .hero strong{font-size:20px}main .throughput{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px}main .metrics{gap:6px}main .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:6px;min-width:0;padding:9px 10px}main .metric strong{font-size:17px;margin:0;white-space:nowrap}main .metric span{font-size:11px}main .card-head{margin-bottom:10px}}</style>
|
|
6
7
|
<style>@media(max-width:600px){main .throughput .metric strong{font-size:17px}}</style>
|
|
@@ -22,7 +23,8 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
|
|
|
22
23
|
@media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
23
24
|
@media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
|
|
24
25
|
</style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.usage-row-head span{min-width:0;overflow-wrap:anywhere}.usage-row-head small{flex-shrink:0;white-space:nowrap}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}</style></head><body><main class="authenticated">
|
|
25
|
-
<header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><button type="button" data-page="usage">使用统计</button></nav></header>
|
|
26
|
+
<style>${adminAuthStyles}${adminWorkstationStyles}</style><header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="workstations">工作站</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><button type="button" data-page="usage">使用统计</button></nav><button id="logout" class="secondary admin-logout" type="button">退出登录</button></header>
|
|
27
|
+
${adminWorkstationsMarkup}
|
|
26
28
|
<section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
|
|
27
29
|
<section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
|
|
28
30
|
<dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
|
|
@@ -30,7 +32,8 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
|
|
|
30
32
|
<section id="usage" class="page" hidden><section class="card"><div class="usage-toolbar"><div><h2>今日使用统计</h2><p id="usageCount" class="hint">正在读取</p></div><div class="usage-views"><button id="usageUsers" type="button" class="secondary" aria-pressed="true">用户</button><button id="usageComputers" type="button" class="secondary" aria-pressed="false">工作站</button><button id="usageRefresh" type="button" class="secondary">刷新</button></div></div><div id="usageRows" class="usage-rows"></div><div class="usage-pages"><button id="usagePrev" type="button" class="secondary">上一页</button><span id="usagePageText" class="hint"></span><button id="usageNext" type="button" class="secondary">下一页</button></div><p id="usageError" class="status error" role="alert"></p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
|
|
31
33
|
</main><footer class="admin-footer">网站备案号:京ICP备2026027643号-2</footer><script>
|
|
32
34
|
const el=id=>document.getElementById(id);let snapshot=null;
|
|
33
|
-
const request=async(path,options={})=>{const response=await fetch(path,{...options,referrerPolicy:'no-referrer'});const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
|
|
35
|
+
const request=async(path,options={})=>{const response=await fetch(path,{...options,referrerPolicy:'no-referrer'});if(response.status===401){location.replace('/');throw new Error('登录已过期,请重新登录')}const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
|
|
36
|
+
el('logout').addEventListener('click',async()=>{el('logout').disabled=true;try{await request('/admin/logout',{method:'POST',headers:{'X-Code-Relax-Admin':'1'}});location.replace('/')}catch(error){setResult(error.message,true)}finally{el('logout').disabled=false}});
|
|
34
37
|
function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
|
|
35
38
|
function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
|
|
36
39
|
function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
|
|
@@ -69,12 +72,16 @@ function usageTime(value){return value?new Date(value.replace(' ','T')+'Z').toLo
|
|
|
69
72
|
async function loadUsage(){const view=usageView,page=usagePage;el('usageError').textContent='';el('usageCount').textContent='正在读取';try{const result=await request('/admin/api/usage?view='+view+'&page='+page);if(view!==usageView||page!==usagePage||el('usage').hidden)return;usageTotal=result.total;el('usageCount').textContent='今日有记录:'+usageTotal+(view==='user'?' 个用户':' 台工作站')+' · 北京时间';el('usagePageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(usageTotal/30))+' 页';el('usagePrev').disabled=page<=1;el('usageNext').disabled=page*30>=usageTotal;const rows=el('usageRows');rows.replaceChildren();if(!result.rows.length){rows.append(node('div','empty-note','今日暂无计时记录'));return}for(const item of result.rows){const row=node('div','usage-row');const counts=' · 消息 '+Number(item.message_count||0)+' · 轮次 '+Number(item.turn_count||0);const heading=node('div','usage-row-head');const title=node('span','',view==='user'?item.email:'工作站 …'+item.id.slice(-8));title.title=view==='user'?item.email:item.id;heading.append(title,node('small','hint','最后使用 '+usageTime(item.last_used_at)));row.append(heading,node('strong','',view==='user'?'任务 '+usageDuration(item.today_task_seconds)+counts:'任务 '+usageDuration(item.today_task_seconds)+' · 免费账号 '+usageDuration(item.today_free_task_seconds)+counts));rows.append(row)}}catch(error){if(view===usageView&&page===usagePage){usageTotal=0;el('usageCount').textContent='统计暂不可用';el('usageRows').replaceChildren();el('usagePageText').textContent='';el('usagePrev').disabled=true;el('usageNext').disabled=true;el('usageError').textContent=error.message}}}
|
|
70
73
|
function setUsageView(view){if(usageView===view)return;usageView=view;usagePage=1;el('usageUsers').setAttribute('aria-pressed',String(view==='user'));el('usageComputers').setAttribute('aria-pressed',String(view==='computer'));void loadUsage()}
|
|
71
74
|
el('usageUsers').onclick=()=>setUsageView('user');el('usageComputers').onclick=()=>setUsageView('computer');el('usageRefresh').onclick=()=>loadUsage();el('usagePrev').onclick=()=>{if(usagePage>1){usagePage--;void loadUsage()}};el('usageNext').onclick=()=>{if(usagePage*30<usageTotal){usagePage++;void loadUsage()}};
|
|
72
|
-
function showPage(page)
|
|
73
|
-
|
|
75
|
+
function showPage(route){cancelWorkstations();const detail=route.startsWith('workstations/')&&/^[a-zA-Z0-9_-]{16,128}$/.test(route.slice(13))?[route,route.slice(13)]:null;const page=detail?'workstations':['overview','announcements','settings','usage','workstations'].includes(route)?route:'overview';workstationId=detail?detail[1]:null;for(const button of document.querySelectorAll('[data-page]'))button.setAttribute('aria-current',button.dataset.page===page?'page':'false');for(const section of document.querySelectorAll('.page'))section.hidden=section.id!==page;if(page==='workstations'){el('workstationBack').hidden=!workstationId;el('workstationHeading').textContent=workstationId?'工作站详情':'工作站';el('workstationCount').textContent='正在读取';el('workstationContent').replaceChildren();el('workstationError').textContent='';void loadWorkstations()}else if(page==='usage')void loadUsage();else void load()}
|
|
76
|
+
function navigatePage(page){history.pushState(null,'','#'+page);showPage(page)}
|
|
77
|
+
for(const button of document.querySelectorAll('[data-page]'))button.addEventListener('click',()=>navigatePage(button.dataset.page));
|
|
78
|
+
window.addEventListener('popstate',()=>showPage(location.hash.slice(1)));
|
|
74
79
|
el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{maxRelaySessions:Number(el('limit').value)},'转发容量已保存'));
|
|
75
80
|
el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
|
|
76
81
|
async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
|
|
77
82
|
el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
${adminWorkstationsScript}
|
|
84
|
+
showPage(location.hash.slice(1));setInterval(()=>{if(document.hidden)return;if(!el('overview').hidden)void load();else if(!el('workstations').hidden)void loadWorkstations()},5000);
|
|
85
|
+
document.addEventListener('visibilitychange',()=>{if(document.hidden)cancelWorkstations();else if(!el('workstations').hidden)void loadWorkstations()});
|
|
86
|
+
</script></body></html>`;
|
|
80
87
|
}
|
|
@@ -3,6 +3,9 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { adminPage } from './admin-page.mjs';
|
|
4
4
|
import { startAdminCloud } from './admin-cloud.mjs';
|
|
5
5
|
import { MAX_ADMIN_REQUEST_BYTES } from './admin-state.mjs';
|
|
6
|
+
import { createAdminAuth } from './admin-auth.mjs';
|
|
7
|
+
import { adminLoginPage } from './admin-login-page.mjs';
|
|
8
|
+
import endpoints from '../../shared/service-endpoints.cjs';
|
|
6
9
|
|
|
7
10
|
function reply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
8
11
|
const bytes = typeof body === 'string' ? body : JSON.stringify(body);
|
|
@@ -12,10 +15,22 @@ function reply(response, status, body, type = 'application/json; charset=utf-8')
|
|
|
12
15
|
response.end(bytes);
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
async function readBody(request) {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let size = 0;
|
|
21
|
+
for await (const chunk of request) {
|
|
22
|
+
size += chunk.length;
|
|
23
|
+
if (size > MAX_ADMIN_REQUEST_BYTES) throw Object.assign(new Error('Admin request too large'), { status: 413 });
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
}
|
|
26
|
+
return Buffer.concat(chunks);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.0.1:45850', accessFile = '/etc/codex-relay/admin-access.json',
|
|
30
|
+
passwordFile = '/etc/codex-relay/admin-password.json', publicOrigin = endpoints.adminOrigin } = {}) {
|
|
16
31
|
const { key } = JSON.parse(fs.readFileSync(accessFile, 'utf8'));
|
|
17
32
|
if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Invalid admin access key');
|
|
18
|
-
const
|
|
33
|
+
const auth = createAdminAuth(passwordFile);
|
|
19
34
|
const cloud = startAdminCloud();
|
|
20
35
|
const relayOrigin = new URL(relayUrl);
|
|
21
36
|
if (relayOrigin.hostname !== '127.0.0.1' || relayOrigin.protocol !== 'http:' || relayOrigin.pathname !== '/') {
|
|
@@ -24,16 +39,38 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
24
39
|
const server = http.createServer(async (request, response) => {
|
|
25
40
|
const target = new URL(request.url || '/', 'http://localhost');
|
|
26
41
|
if (request.method === 'GET' && target.pathname === '/admin/health') { reply(response, 200, { ok: true }); return; }
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
if (!['GET', 'HEAD'].includes(request.method) && (request.headers.origin !== publicOrigin
|
|
43
|
+
|| request.headers['sec-fetch-site'] === 'cross-site' || request.headers['x-code-relax-admin'] !== '1')) {
|
|
44
|
+
reply(response, 403, { error: 'Cross-site request rejected' }); return;
|
|
45
|
+
}
|
|
46
|
+
if (request.method === 'GET' && target.pathname === '/') {
|
|
47
|
+
reply(response, 200, auth.authenticated(request) ? adminPage() : adminLoginPage(), 'text/html; charset=utf-8'); return;
|
|
48
|
+
}
|
|
30
49
|
if (request.method === 'GET' && ['/admin', '/admin/'].includes(target.pathname)) {
|
|
31
|
-
|
|
50
|
+
response.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' }); response.end(); return;
|
|
32
51
|
}
|
|
33
|
-
if (request.method === '
|
|
34
|
-
|
|
52
|
+
if (request.method === 'POST' && target.pathname === '/admin/login') {
|
|
53
|
+
try {
|
|
54
|
+
if (request.headers['content-type']?.split(';')[0] !== 'application/json') {
|
|
55
|
+
reply(response, 415, { error: 'Expected application/json' }); return;
|
|
56
|
+
}
|
|
57
|
+
const { password } = JSON.parse((await readBody(request)).toString('utf8'));
|
|
58
|
+
const result = await auth.login(password, request, response);
|
|
59
|
+
reply(response, result.status, result.error ? { error: result.error } : { ok: true });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const status = error.status || (error instanceof SyntaxError || error instanceof TypeError ? 400 : 500);
|
|
62
|
+
if (status === 500) console.error('Admin login failed:', error.message);
|
|
63
|
+
reply(response, status, { error: status === 500 ? '登录服务暂不可用' : '登录请求无效' });
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
35
66
|
}
|
|
36
|
-
if (request.method === '
|
|
67
|
+
if (request.method === 'POST' && target.pathname === '/admin/logout') {
|
|
68
|
+
auth.logout(request, response); reply(response, 200, { ok: true }); return;
|
|
69
|
+
}
|
|
70
|
+
if (!target.pathname.startsWith('/admin/api/')) { reply(response, 404, { error: 'Not found' }); return; }
|
|
71
|
+
if (!auth.authenticated(request)) { reply(response, 401, { error: '登录已过期,请重新登录' }); return; }
|
|
72
|
+
if (request.method === 'GET' && (['/admin/api/status', '/admin/api/usage', '/admin/api/usage-diagnostics',
|
|
73
|
+
'/admin/api/workstations'].includes(target.pathname) || /^\/admin\/api\/workstations\/[\w-]{16,128}$/.test(target.pathname))) {
|
|
37
74
|
try {
|
|
38
75
|
const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
|
|
39
76
|
signal: AbortSignal.timeout(3000), headers: { 'X-Code-Relax-Admin-Key': key } });
|
|
@@ -50,22 +87,16 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
|
|
|
50
87
|
reply(response, 404, { error: 'Not found' }); return;
|
|
51
88
|
}
|
|
52
89
|
try {
|
|
53
|
-
const
|
|
54
|
-
let size = 0;
|
|
55
|
-
for await (const chunk of request) {
|
|
56
|
-
size += chunk.length;
|
|
57
|
-
if (size > MAX_ADMIN_REQUEST_BYTES) { reply(response, 413, { error: 'Admin request too large' }); return; }
|
|
58
|
-
chunks.push(chunk);
|
|
59
|
-
}
|
|
90
|
+
const body = await readBody(request);
|
|
60
91
|
const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
|
|
61
92
|
method: request.method, signal: AbortSignal.timeout(3000),
|
|
62
93
|
headers: { 'Content-Type': request.headers['content-type'] || '',
|
|
63
94
|
'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '',
|
|
64
95
|
'X-Code-Relax-Admin-Key': key },
|
|
65
|
-
body
|
|
96
|
+
body,
|
|
66
97
|
});
|
|
67
98
|
reply(response, upstream.status, await upstream.text());
|
|
68
|
-
} catch { reply(response, 503, { error: '中继服务暂不可用' }); }
|
|
99
|
+
} catch (error) { reply(response, error.status || 503, { error: error.status === 413 ? error.message : '中继服务暂不可用' }); }
|
|
69
100
|
});
|
|
70
101
|
try {
|
|
71
102
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|