@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.
@@ -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);
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.51"}
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
- completed.clear(); completedBytes = 0;
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, uploadsRoot }) {
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)) identity = 'local-token';
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') upload = createFileUploadSession(uploadsRoot);
92
- reply({ type: 'ready', version: 2, purpose, maxDownloadBytes: FILE_READ_LIMIT, maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
93
- maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES });
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
- }).catch(error => { reply({ type: 'error', error: error.message }); stop(); ws.close(1008, 'File request rejected'); })
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
- const deadline = Date.now() + readyTimeoutMs;
191
- while (!await host.desktopReady()) {
192
- if (Date.now() >= deadline) throw new Error('Codex Desktop 尚未重新就绪。');
193
- await wait(500);
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
- const finalState = parseState(await fs.readFile(stateFile));
202
- const verified = transform(finalState, project, codexDataRoot);
203
- if (JSON.stringify(verified) !== JSON.stringify(finalState)) throw new Error('Desktop 项目状态未能在最终文件中核对,请检查后继续完成操作。');
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(basePath = '/admin') {
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){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;history.replaceState(null,'','#'+page);if(page==='usage')void loadUsage();else void load()}
73
- for(const button of document.querySelectorAll('[data-page]'))button.addEventListener('click',()=>showPage(button.dataset.page));
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
- showPage(['overview','announcements','settings','usage'].includes(location.hash.slice(1))?location.hash.slice(1):'overview');setInterval(()=>{if(!document.hidden&&!el('overview').hidden)load()},5000);
79
- </script></body></html>`.replaceAll('/admin/api/', basePath + '/api/');
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
- export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.0.1:45850', accessFile = '/etc/codex-relay/admin-access.json' } = {}) {
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 basePath = '/admin/' + key;
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 (request.headers['sec-fetch-site'] === 'cross-site' && request.method !== 'GET') { reply(response, 403, { error: 'Cross-site request rejected' }); return; }
28
- if (target.pathname !== basePath && !target.pathname.startsWith(basePath + '/')) { reply(response, 404, { error: 'Not found' }); return; }
29
- target.pathname = '/admin' + target.pathname.slice(basePath.length);
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
- reply(response, 200, adminPage(basePath), 'text/html; charset=utf-8'); return;
50
+ response.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' }); response.end(); return;
32
51
  }
33
- if (request.method === 'GET' && target.pathname === '/admin/usage') {
34
- response.writeHead(302, { Location: basePath + '#usage', 'Cache-Control': 'no-store' }); response.end(); return;
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 === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage', '/admin/api/usage-diagnostics'].includes(target.pathname)) {
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 chunks = [];
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: Buffer.concat(chunks),
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); });