@jacksontian/kite-server 0.1.0 → 0.3.0

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/server.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * 职责:签名鉴权、任务信箱(手机写入 / Worker 领取)、日志与结果收集、
6
6
  * Worker 心跳、托管手机端 Web 控制台。
7
7
  *
8
- * 依赖:ws(WebSocket)、dingbot(钉钉群机器人)、yaml。其余为 Node 内置模块。Node >= 24。
8
+ * 依赖:ws(WebSocket)、dingbot(钉钉群机器人)、yaml、zod(配置校验,见 config.js)。
9
+ * 其余为 Node 内置模块。Node >= 24。
9
10
  *
10
11
  * 鉴权模型:HMAC-SHA256 请求签名。密钥(API_SECRET)只存在两端,
11
12
  * 永不在网络上传输;HTTP 明文下抓到请求也无法重放(时间窗 + nonce)。
@@ -18,7 +19,14 @@
18
19
  *
19
20
  * 配置文件(--config 传入的 YAML 文件,唯一配置来源,不再读取环境变量;
20
21
  * 字段说明与模板见包内 config.example.yaml):
21
- * apiSecret 必填,两端共享的签名密钥(强随机 hex,见 README 快速开始)
22
+ * apiSecret 必填,控制台签名密钥(强随机 hex,见 README 快速开始)
23
+ * workerSecret 可选,Worker 专用密钥;配置后 Worker 侧 API 与 /worker/ws
24
+ * 只认该密钥,控制台密钥泄露不再等于 Worker 执行权;
25
+ * 留空则两端共用 apiSecret
26
+ * workerSecrets 可选,workerId -> 密钥映射(多 Worker 各自专属密钥);
27
+ * 验签命中映射条目即绑定该 Worker 身份(自报 workerId 须一致),
28
+ * Worker 间无法互相冒充;与 workerSecret 可并存(命中共享
29
+ * 密钥时身份自报,兼容旧部署)
22
30
  * port 可选,默认 8787
23
31
  * dataDir 可选,持久化目录,默认 ~/.kite/data;
24
32
  * 相对路径相对启动工作目录解析,建议写绝对路径;
@@ -47,6 +55,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
47
55
  const {
48
56
  configFile: CONFIG_FILE,
49
57
  apiSecret: API_SECRET,
58
+ workerAuth: WORKER_AUTH,
50
59
  port: PORT,
51
60
  dataDir: DATA_DIR,
52
61
  maxTasks: MAX_TASKS,
@@ -60,6 +69,9 @@ const {
60
69
  const WEB_DIR = path.join(__dirname, 'web');
61
70
  const DB_FILE = path.join(DATA_DIR, 'tasks.json');
62
71
  const MAX_LOG_LINES = 2000;
72
+ // pending 任务数上限:密钥泄露/控制台失守时防无限灌任务撑爆内存与 tasks.json
73
+ // (pruneFinishedTasks 只清终态,堆积的 pending 没有任何自然出口)
74
+ const MAX_PENDING_TASKS = 100;
63
75
 
64
76
  // ---------------------------------------------------------------------------
65
77
  // 持久化:单文件 JSON,异步落盘(脏标记 + 节流 + 串行化,全程无同步 IO)
@@ -112,7 +124,9 @@ async function flushSave() {
112
124
  try {
113
125
  const data = JSON.stringify(state, null, 2);
114
126
  const tmp = DB_FILE + '.tmp';
115
- await fs.promises.writeFile(tmp, data);
127
+ // 0600:tasks.json 含全部命令/日志/结果,不允许同机其他用户读取
128
+ await fs.promises.writeFile(tmp, data, { mode: 0o600 });
129
+ await fs.promises.chmod(tmp, 0o600); // writeFile 的 mode 只在创建时生效,历史文件需显式收紧
116
130
  await fs.promises.rename(tmp, DB_FILE);
117
131
  } catch (err) {
118
132
  console.error(`[server] 落盘失败,下次变更时重试: ${err.message}`);
@@ -140,6 +154,15 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
140
154
  // pty 交互会话例外:时长不可预期,由 PTY_IDLE_SEC 空闲回收(见 sweepPtyIdle)
141
155
  function sweepStaleRunning() {
142
156
  const now = Date.now();
157
+ // 顺带清理离线超过 7 天的 Worker 心跳记录,防止 heartbeats 无限增长
158
+ let hbPruned = false;
159
+ for (const [id, hb] of Object.entries(state.heartbeats)) {
160
+ if (now - hb.at > 7 * 24 * 3600 * 1000) {
161
+ delete state.heartbeats[id];
162
+ hbPruned = true;
163
+ }
164
+ }
165
+ if (hbPruned) scheduleSave();
143
166
  for (const t of state.tasks) {
144
167
  if (t.status !== 'running' || t.type === 'pty') continue;
145
168
  const hb = t.workerId ? state.heartbeats[t.workerId] : null;
@@ -180,6 +203,8 @@ function json(res, code, body) {
180
203
  // ---------------------------------------------------------------------------
181
204
  // 签名鉴权:HMAC-SHA256(密钥, METHOD\nPATH\nTS\nNONCE\nSHA256(BODY))
182
205
  // 密钥永不上网;时间窗 + nonce 缓存联合防重放。
206
+ // 密钥按路由角色选择:控制台 API 用 API_SECRET,Worker API 用 WORKER_AUTH
207
+ // 条目列表(workerSecrets 绑定密钥 / 共享 workerSecret / 回落 apiSecret)。
183
208
  // ---------------------------------------------------------------------------
184
209
 
185
210
  const seenNonces = new Map(); // nonce -> 过期时间戳(ms)
@@ -193,7 +218,7 @@ function pruneNonces(now) {
193
218
 
194
219
  // 校验 ts/nonce/签名三元组;通过即消费掉该 nonce。
195
220
  // signingParts 为参与签名的各行(不含密钥),body 为原始字节(可为空 Buffer)。
196
- function verifySignature(signingParts, tsRaw, nonce, sig) {
221
+ function verifySignature(signingParts, tsRaw, nonce, sig, secret) {
197
222
  const now = Date.now();
198
223
  if (!tsRaw || !nonce || !sig) {
199
224
  return { ok: false, reason: '缺少签名头 X-RW-Ts/X-RW-Nonce/X-RW-Sig' };
@@ -215,7 +240,7 @@ function verifySignature(signingParts, tsRaw, nonce, sig) {
215
240
  return { ok: false, reason: 'nonce 重复(疑似重放)' };
216
241
  }
217
242
  const signingString = signingParts.join('\n');
218
- const expected = crypto.createHmac('sha256', API_SECRET).update(signingString).digest('hex');
243
+ const expected = crypto.createHmac('sha256', secret).update(signingString).digest('hex');
219
244
  if (!timingSafeEqualStr(String(sig), expected)) {
220
245
  return { ok: false, reason: '签名不匹配(密钥错误或请求被篡改)' };
221
246
  }
@@ -231,7 +256,9 @@ function readRawBody(req, limit = 1024 * 1024) {
231
256
  req.on('data', (chunk) => {
232
257
  size += chunk.length;
233
258
  if (size > limit) {
234
- reject(new Error('body too large'));
259
+ const err = new Error('body too large');
260
+ err.statusCode = 413; // 明确为客户端错误,响应里可安全回显
261
+ reject(err);
235
262
  req.destroy();
236
263
  return;
237
264
  }
@@ -244,16 +271,54 @@ function readRawBody(req, limit = 1024 * 1024) {
244
271
 
245
272
  // REST 请求验签:签名覆盖方法、路径、时间戳、nonce 与请求体摘要。
246
273
  // 注意:路径用含 query 的完整形式(与客户端签名方式一致,防止改写查询参数)。
247
- async function verifyRestSignature(req, pathname) {
274
+ // entries 为该路由可接受的密钥条目列表({ secret, workerId },按序尝试,
275
+ // 验过任一即通过);workerId 非 null 表示该密钥绑定了 Worker 身份
276
+ // (workerSecrets 条目),null 表示身份自报(共享密钥兼容路径)。
277
+ // nonce 只在验过之后消费,失败尝试不留副作用。
278
+ async function verifyRestSignature(req, entries) {
248
279
  const rawBody = await readRawBody(req);
249
280
  const bodySha = crypto.createHash('sha256').update(rawBody).digest('hex');
250
- const result = verifySignature(
251
- [req.method, req.url, req.headers['x-rw-ts'], req.headers['x-rw-nonce'], bodySha],
252
- req.headers['x-rw-ts'],
253
- req.headers['x-rw-nonce'],
254
- req.headers['x-rw-sig'],
255
- );
256
- return { ...result, rawBody };
281
+ const parts = [req.method, req.url, req.headers['x-rw-ts'], req.headers['x-rw-nonce'], bodySha];
282
+ let result = { ok: false, reason: '缺少签名头 X-RW-Ts/X-RW-Nonce/X-RW-Sig' };
283
+ let workerId = null;
284
+ for (const entry of entries) {
285
+ result = verifySignature(parts, req.headers['x-rw-ts'], req.headers['x-rw-nonce'], req.headers['x-rw-sig'], entry.secret);
286
+ if (result.ok) {
287
+ workerId = entry.workerId;
288
+ break;
289
+ }
290
+ }
291
+ return { ...result, rawBody, workerId };
292
+ }
293
+
294
+ // 路由 -> 可接受的密钥条目:Worker 侧 API 只认 WORKER_AUTH 条目,控制台只认
295
+ // API_SECRET;worker/status 两端都要读(控制台轮询兜底也用),两边密钥都放行
296
+ const WORKER_PATH = /^\/api\/(worker\/(claim|heartbeat)|tasks\/[0-9a-f-]{36}\/(logs|finish))$/;
297
+ function secretsForPath(pathname) {
298
+ if (pathname === '/api/worker/status') {
299
+ return [{ secret: API_SECRET, workerId: null }, ...WORKER_AUTH];
300
+ }
301
+ return WORKER_PATH.test(pathname) ? WORKER_AUTH : [{ secret: API_SECRET, workerId: null }];
302
+ }
303
+
304
+ // 验签失败限流:单 IP 滑动窗口(60s/30 次),防止恶意刷 401 灌日志耗 CPU;
305
+ // 只统计失败,成功请求不计(正常轮询频率不会触限)
306
+ const authFailures = new Map(); // ip -> { count, windowStart }
307
+ function authLimitExceeded(ip) {
308
+ const rec = authFailures.get(ip);
309
+ return Boolean(rec && Date.now() - rec.windowStart <= 60_000 && rec.count >= 30);
310
+ }
311
+ function recordAuthFailure(ip) {
312
+ const now = Date.now();
313
+ if (authFailures.size > 10_000) {
314
+ for (const [k, rec] of authFailures) if (now - rec.windowStart > 60_000) authFailures.delete(k);
315
+ }
316
+ let rec = authFailures.get(ip);
317
+ if (!rec || now - rec.windowStart > 60_000) {
318
+ rec = { count: 0, windowStart: now };
319
+ authFailures.set(ip, rec);
320
+ }
321
+ rec.count++;
257
322
  }
258
323
 
259
324
  function parseBody(rawBody) {
@@ -268,7 +333,8 @@ function parseBody(rawBody) {
268
333
  }
269
334
 
270
335
  function publicTask(task) {
271
- // 不外泄内部字段的浅拷贝
336
+ // 浅拷贝切断与内部状态的引用别名,防止响应侧改动波及 state.tasks;
337
+ // 任务现有字段(含 logs/result/payload)均为控制台可见,无需裁剪
272
338
  return { ...task };
273
339
  }
274
340
 
@@ -288,6 +354,7 @@ function taskSummary(t) {
288
354
  startedAt: t.startedAt,
289
355
  finishedAt: t.finishedAt,
290
356
  workerId: t.workerId,
357
+ targetWorker: t.targetWorker || null,
291
358
  logCount: t.logs.length,
292
359
  ...shellPreview(t),
293
360
  };
@@ -334,9 +401,10 @@ function broadcast(msg) {
334
401
  // Worker 收到后仍走 HTTP claim 原子领取,断线期间由轮询兜底
335
402
  const wsWorkers = new Set();
336
403
 
337
- function notifyWorkers() {
404
+ function notifyWorkers(targetWorker = null) {
338
405
  if (!wsWorkers.size) return;
339
- const data = JSON.stringify({ type: 'task.available' });
406
+ // 附带目标 Worker:定向任务无关的 Worker 收到后不领取,省一次空 claim
407
+ const data = JSON.stringify({ type: 'task.available', targetWorker });
340
408
  for (const ws of wsWorkers) ws.send(data);
341
409
  }
342
410
 
@@ -470,14 +538,33 @@ function sweepPtyIdle() {
470
538
  // API 路由
471
539
  // ---------------------------------------------------------------------------
472
540
 
473
- async function handleApi(req, res, pathname, rawBody) {
541
+ // 身份绑定:配置 workerSecrets 时验签密钥即决定 Worker 身份;
542
+ // 自报 workerId 与绑定身份不一致返回 null,调用方按 401 拒绝
543
+ function resolveWorkerId(authedWorkerId, bodyWorkerId) {
544
+ const claimed = String(bodyWorkerId || 'unknown').slice(0, 128);
545
+ if (authedWorkerId && claimed !== authedWorkerId) return null;
546
+ return authedWorkerId || claimed;
547
+ }
548
+
549
+ // 归属校验(仅身份绑定生效时启用):只允许领取者上报日志/结果,
550
+ // 防止其他 Worker 伪造他人任务(含未领取任务)的日志与终态;
551
+ // 共享密钥模式(authedWorkerId 为 null)维持旧行为
552
+ function canReportTask(task, authedWorkerId) {
553
+ return !authedWorkerId || task.workerId === authedWorkerId;
554
+ }
555
+
556
+ async function handleApi(req, res, pathname, rawBody, authedWorkerId) {
474
557
  sweepStaleRunning();
475
558
  const body = parseBody(rawBody);
476
559
 
477
560
  // --- Worker:领取最老的 pending 任务(原子置为 running)---
478
561
  if (req.method === 'POST' && pathname === '/api/worker/claim') {
479
- const workerId = String(body.workerId || 'unknown').slice(0, 128);
480
- const task = state.tasks.find((t) => t.status === 'pending');
562
+ const workerId = resolveWorkerId(authedWorkerId, body.workerId);
563
+ if (!workerId) return json(res, 401, { error: 'workerId 与密钥绑定的身份不一致' });
564
+ // 只领取指向本 Worker 或未指定目标(任意)的任务
565
+ const task = state.tasks.find(
566
+ (t) => t.status === 'pending' && (!t.targetWorker || t.targetWorker === workerId),
567
+ );
481
568
  if (!task) return json(res, 200, { task: null });
482
569
  task.status = 'running';
483
570
  task.workerId = workerId;
@@ -489,7 +576,8 @@ async function handleApi(req, res, pathname, rawBody) {
489
576
 
490
577
  // --- Worker:心跳 ---
491
578
  if (req.method === 'POST' && pathname === '/api/worker/heartbeat') {
492
- const workerId = String(body.workerId || 'unknown').slice(0, 128);
579
+ const workerId = resolveWorkerId(authedWorkerId, body.workerId);
580
+ if (!workerId) return json(res, 401, { error: 'workerId 与密钥绑定的身份不一致' });
493
581
  state.heartbeats[workerId] = {
494
582
  at: Date.now(),
495
583
  hostname: String(body.hostname || '').slice(0, 128),
@@ -509,12 +597,25 @@ async function handleApi(req, res, pathname, rawBody) {
509
597
  return json(res, 200, { workers: workersSnapshot() });
510
598
  }
511
599
 
512
- // --- 创建任务 ---
600
+ // --- 创建任务(pending 超限拒绝,见 MAX_PENDING_TASKS)---
513
601
  if (req.method === 'POST' && pathname === '/api/tasks') {
514
602
  const type = String(body.type || '');
515
603
  if (!['script', 'shell', 'agent', 'chat', 'pty'].includes(type)) {
516
604
  return json(res, 400, { error: 'type 必须是 script|shell|agent|chat|pty' });
517
605
  }
606
+ const pendingCount = state.tasks.filter((t) => t.status === 'pending').length;
607
+ if (pendingCount >= MAX_PENDING_TASKS) {
608
+ return json(res, 429, { error: `待执行任务已达上限(${MAX_PENDING_TASKS}),请先消化积压` });
609
+ }
610
+ // 定向任务:只允许指定 Worker 领取;空/未传 = 任意 Worker 均可领取
611
+ let targetWorker = null;
612
+ if (body.targetWorker) {
613
+ const tw = String(body.targetWorker).trim().slice(0, 128);
614
+ if (!/^[\w.-]{1,128}$/.test(tw)) {
615
+ return json(res, 400, { error: 'targetWorker 只能含字母/数字/._-' });
616
+ }
617
+ targetWorker = tw;
618
+ }
518
619
  const task = {
519
620
  id: crypto.randomUUID(),
520
621
  type,
@@ -527,11 +628,12 @@ async function handleApi(req, res, pathname, rawBody) {
527
628
  startedAt: null,
528
629
  finishedAt: null,
529
630
  workerId: null,
631
+ targetWorker,
530
632
  };
531
633
  state.tasks.push(task);
532
634
  scheduleSave();
533
635
  broadcast({ type: 'task.created', task: taskSummary(task) });
534
- notifyWorkers(); // 任务到达即推送,Worker 立即领取,不再等轮询周期
636
+ notifyWorkers(targetWorker); // 任务到达即推送,Worker 立即领取,不再等轮询周期
535
637
  return json(res, 201, publicTask(task));
536
638
  }
537
639
 
@@ -559,6 +661,9 @@ async function handleApi(req, res, pathname, rawBody) {
559
661
  if (req.method === 'POST' && logMatch) {
560
662
  const task = state.tasks.find((t) => t.id === logMatch[1]);
561
663
  if (!task) return json(res, 404, { error: 'task not found' });
664
+ if (!canReportTask(task, authedWorkerId)) {
665
+ return json(res, 403, { error: '只能上报自己领取的任务' });
666
+ }
562
667
  const lines = Array.isArray(body.lines) ? body.lines : [];
563
668
  for (const line of lines) {
564
669
  task.logs.push(String(line).slice(0, 4000));
@@ -576,6 +681,14 @@ async function handleApi(req, res, pathname, rawBody) {
576
681
  if (req.method === 'POST' && finishMatch) {
577
682
  const task = state.tasks.find((t) => t.id === finishMatch[1]);
578
683
  if (!task) return json(res, 404, { error: 'task not found' });
684
+ if (!canReportTask(task, authedWorkerId)) {
685
+ return json(res, 403, { error: '只能上报自己领取的任务' });
686
+ }
687
+ // 状态守卫:已到终态(被 sweepStaleRunning 判失败回收、控制台取消等)的任务
688
+ // 拒绝迟到的 finish,防止短暂失联后"复活"的 Worker 把终态翻回
689
+ if (task.status !== 'running') {
690
+ return json(res, 409, { error: `任务已处于终态(${task.status}),拒绝 finish 上报` });
691
+ }
579
692
  task.status = body.status === 'done' ? 'done' : 'failed';
580
693
  task.result = body.result ?? null;
581
694
  task.finishedAt = Date.now();
@@ -615,7 +728,7 @@ async function handleApi(req, res, pathname, rawBody) {
615
728
  }
616
729
 
617
730
  // ---------------------------------------------------------------------------
618
- // 静态文件(手机端 Web 控制台)
731
+ // 静态文件(Web 控制台:移动端 index.html / PC 端 pc.html,按 UA 分发)
619
732
  // ---------------------------------------------------------------------------
620
733
 
621
734
  const MIME = {
@@ -626,10 +739,18 @@ const MIME = {
626
739
  '.png': 'image/png',
627
740
  };
628
741
 
742
+ // 移动端 UA 特征词;命中走手机版,其余(桌面浏览器)走 PC 版。
743
+ // iPadOS 13+ 默认伪装 Mac UA,会落到 PC 版——需要时可显式访问 /index.html
744
+ const MOBILE_UA = /android|iphone|ipod|ipad|mobile|harmonyos/i;
745
+
629
746
  function serveStatic(req, res, pathname) {
630
- const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
631
- const filePath = path.join(WEB_DIR, rel);
632
- if (!filePath.startsWith(WEB_DIR)) {
747
+ let rel = pathname.replace(/^\/+/, '');
748
+ if (!rel) {
749
+ rel = MOBILE_UA.test(req.headers['user-agent'] || '') ? 'index.html' : 'pc.html';
750
+ }
751
+ // resolve 归一化 ../ 后按「目录 + 分隔符」前缀判断,避免同级前缀目录(web-xxx)绕过
752
+ const filePath = path.resolve(WEB_DIR, rel);
753
+ if (filePath !== WEB_DIR && !filePath.startsWith(WEB_DIR + path.sep)) {
633
754
  res.writeHead(403);
634
755
  return res.end('forbidden');
635
756
  }
@@ -641,6 +762,13 @@ function serveStatic(req, res, pathname) {
641
762
  res.writeHead(200, {
642
763
  'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream',
643
764
  'Cache-Control': 'no-store',
765
+ // 纵深防御:页面资源一律限同源,阻断潜在注入脚本把密钥外传(connect-src
766
+ // 的 'self' 按 CSP3 含同源 ws/wss,实时通道不受影响);页面含内联 <script>
767
+ // 与行内样式操作,script/style 需放行 'unsafe-inline'
768
+ 'Content-Security-Policy':
769
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
770
+ "img-src 'self' data:; connect-src 'self'",
771
+ 'X-Content-Type-Options': 'nosniff',
644
772
  });
645
773
  res.end(data);
646
774
  });
@@ -651,29 +779,46 @@ function serveStatic(req, res, pathname) {
651
779
  // ---------------------------------------------------------------------------
652
780
 
653
781
  const server = http.createServer(async (req, res) => {
782
+ let pathname = '';
654
783
  try {
655
784
  const url = new URL(req.url, 'http://localhost');
656
- const pathname = url.pathname;
785
+ pathname = url.pathname;
786
+
787
+ // 探活端点:反代/uptime 监控(Caddy health、Uptime Kuma 等)直接 GET 即可,
788
+ // 不参与签名鉴权,只回固定 { ok: true },不暴露任何状态与数据
789
+ if (req.method === 'GET' && pathname === '/health') {
790
+ return json(res, 200, { ok: true });
791
+ }
657
792
 
658
793
  if (pathname.startsWith('/api/')) {
659
- // 先验签(读原始 body 计算摘要),通过后才进入业务路由
660
- const auth = await verifyRestSignature(req, pathname);
794
+ // 验签失败限流:超阈值直接拒,不再读 body 做 HMAC,防止刷 401 耗资源
795
+ if (authLimitExceeded(req.socket.remoteAddress)) {
796
+ return json(res, 429, { error: 'auth failures rate limited' });
797
+ }
798
+ // 先验签(读原始 body 计算摘要),通过后才进入业务路由;
799
+ // 密钥按路由角色选择(见 secretsForPath)
800
+ const auth = await verifyRestSignature(req, secretsForPath(pathname));
661
801
  if (!auth.ok) {
802
+ recordAuthFailure(req.socket.remoteAddress);
662
803
  console.warn(
663
804
  `[server] 拒绝请求 ${req.method} ${pathname},来源 ${req.socket.remoteAddress}:${auth.reason}`,
664
805
  );
665
806
  return json(res, 401, { error: 'unauthorized' });
666
807
  }
667
- return await handleApi(req, res, pathname, auth.rawBody);
808
+ return await handleApi(req, res, pathname, auth.rawBody, auth.workerId);
668
809
  }
669
810
 
670
811
  if (req.method === 'GET') return serveStatic(req, res, pathname);
671
812
 
672
813
  return json(res, 405, { error: 'method not allowed' });
673
814
  } catch (err) {
674
- return json(res, err.statusCode || (err.message === 'invalid json' ? 400 : 500), {
675
- error: err.message || 'internal error',
676
- });
815
+ const statusCode = err.statusCode || (err.message === 'invalid json' ? 400 : 500);
816
+ if (statusCode >= 500) {
817
+ // 内部异常只记服务端日志,响应不外泄细节(可能含路径等内部信息)
818
+ console.error(`[server] 请求处理异常 ${req.method} ${pathname}: ${err.stack || err.message}`);
819
+ return json(res, 500, { error: 'internal error' });
820
+ }
821
+ return json(res, statusCode, { error: err.message || 'bad request' });
677
822
  }
678
823
  });
679
824
 
@@ -681,7 +826,9 @@ const server = http.createServer(async (req, res) => {
681
826
  // WebSocket 连接管理
682
827
  // ---------------------------------------------------------------------------
683
828
 
684
- const wss = new WebSocketServer({ noServer: true });
829
+ // maxPayload:限制单帧 1MiB(pty 单帧输出/输入远小于此),
830
+ // 杜绝默认 100MiB 大帧配合广播造成的内存放大
831
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
685
832
 
686
833
  wss.on('connection', (ws, req) => {
687
834
  const remote = req.socket.remoteAddress;
@@ -704,12 +851,14 @@ wss.on('connection', (ws, req) => {
704
851
  ws.close(4002, 'bad auth message');
705
852
  return;
706
853
  }
707
- // 签名鉴权:sig = HMAC(密钥, "WS\nTS\nNONCE"),同样走时间窗 + nonce 防重放
854
+ // 签名鉴权:sig = HMAC(密钥, "WS\nTS\nNONCE"),同样走时间窗 + nonce 防重放;
855
+ // 控制台通道只认控制台密钥
708
856
  const result = verifySignature(
709
857
  ['WS', String(msg.ts), String(msg.nonce)],
710
858
  msg.ts,
711
859
  msg.nonce,
712
860
  msg.sig,
861
+ API_SECRET,
713
862
  );
714
863
  if (!result.ok) {
715
864
  console.warn(`[server] 拒绝 WS 连接,来源 ${remote}:${result.reason}`);
@@ -787,7 +936,7 @@ wss.on('connection', (ws, req) => {
787
936
 
788
937
  // --- Worker 通道 /worker/ws:鉴权方式同 /ws;鉴权后下发 task.available,
789
938
  // 并承载 pty 会话上行消息(session.started/output/exited)---
790
- const workerWss = new WebSocketServer({ noServer: true });
939
+ const workerWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
791
940
 
792
941
  workerWss.on('connection', (ws, req) => {
793
942
  const remote = req.socket.remoteAddress;
@@ -809,24 +958,45 @@ workerWss.on('connection', (ws, req) => {
809
958
  ws.close(4002, 'bad auth message');
810
959
  return;
811
960
  }
812
- const result = verifySignature(
813
- ['WS', String(msg.ts), String(msg.nonce)],
814
- msg.ts,
815
- msg.nonce,
816
- msg.sig,
817
- );
961
+ // Worker 通道只认 Worker 侧密钥:控制台密钥泄露不能冒充 Worker 领任务。
962
+ // 逐一尝试 WORKER_AUTH 条目;命中 workerSecrets 绑定密钥时身份以绑定
963
+ // 为准(自报 workerId 不一致即拒绝),共享密钥为兼容路径(身份自报)
964
+ let result = { ok: false, reason: '缺少签名头' };
965
+ let boundId = null;
966
+ for (const entry of WORKER_AUTH) {
967
+ result = verifySignature(
968
+ ['WS', String(msg.ts), String(msg.nonce)],
969
+ msg.ts,
970
+ msg.nonce,
971
+ msg.sig,
972
+ entry.secret,
973
+ );
974
+ if (result.ok) {
975
+ boundId = entry.workerId;
976
+ break;
977
+ }
978
+ }
818
979
  if (!result.ok) {
819
980
  console.warn(`[server] 拒绝 Worker WS 连接,来源 ${remote}:${result.reason}`);
820
981
  ws.close(4003, 'auth failed');
821
982
  return;
822
983
  }
823
984
  clearTimeout(authTimer);
985
+ const claimedId = String(msg.workerId || 'unknown').slice(0, 128);
986
+ if (boundId && claimedId !== boundId) {
987
+ console.warn(
988
+ `[server] 拒绝 Worker WS 连接,来源 ${remote}:workerId "${claimedId}" 与密钥绑定身份 "${boundId}" 不一致`,
989
+ );
990
+ ws.close(4003, 'identity mismatch');
991
+ return;
992
+ }
824
993
  ws.isAuthed = true;
825
- ws.workerId = String(msg.workerId || 'unknown').slice(0, 128);
994
+ ws.workerId = boundId || claimedId;
826
995
  wsWorkers.add(ws);
827
996
  console.log(`[server] Worker ${ws.workerId} 通道已连接(来源 ${remote})`);
828
997
  // 断线窗口内可能已有积压任务,连接建立即补一次通知
829
- if (state.tasks.some((t) => t.status === 'pending')) {
998
+ // (只推指向本 Worker 或未指定目标的,避免无关 Worker 空领)
999
+ if (state.tasks.some((t) => t.status === 'pending' && (!t.targetWorker || t.targetWorker === ws.workerId))) {
830
1000
  ws.send(JSON.stringify({ type: 'task.available' }));
831
1001
  }
832
1002
  return;
@@ -918,4 +1088,16 @@ server.listen(PORT, () => {
918
1088
  console.log(`[server] kite API listening on :${PORT}`);
919
1089
  console.log(`[server] config file: ${CONFIG_FILE}`);
920
1090
  console.log(`[server] data dir: ${DATA_DIR}`);
1091
+ const boundEntries = WORKER_AUTH.filter((e) => e.workerId);
1092
+ const sharedEntry = WORKER_AUTH.find((e) => !e.workerId);
1093
+ let authMode;
1094
+ if (boundEntries.length) {
1095
+ authMode = `多 Worker 密钥(${boundEntries.map((e) => e.workerId).join(', ')}` +
1096
+ `${sharedEntry ? ',另有共享 workerSecret' : ''})`;
1097
+ } else if (sharedEntry && sharedEntry.secret === API_SECRET) {
1098
+ authMode = '单密钥(控制台与 Worker 共用 apiSecret)';
1099
+ } else {
1100
+ authMode = '双密钥(控制台/Worker 已分离)';
1101
+ }
1102
+ console.log(`[server] auth mode: ${authMode}`);
921
1103
  });