@jacksontian/kite-server 0.1.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/config.example.yaml +37 -0
- package/config.js +205 -0
- package/package.json +37 -0
- package/server.js +921 -0
- package/web/index.html +900 -0
package/server.js
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kite 云端信箱 API
|
|
4
|
+
*
|
|
5
|
+
* 职责:签名鉴权、任务信箱(手机写入 / Worker 领取)、日志与结果收集、
|
|
6
|
+
* Worker 心跳、托管手机端 Web 控制台。
|
|
7
|
+
*
|
|
8
|
+
* 依赖:ws(WebSocket)、dingbot(钉钉群机器人)、yaml。其余为 Node 内置模块。Node >= 24。
|
|
9
|
+
*
|
|
10
|
+
* 鉴权模型:HMAC-SHA256 请求签名。密钥(API_SECRET)只存在两端,
|
|
11
|
+
* 永不在网络上传输;HTTP 明文下抓到请求也无法重放(时间窗 + nonce)。
|
|
12
|
+
* 签名串:方法、路径、时间戳、nonce、请求体 SHA256,五行以换行符连接
|
|
13
|
+
* 请求头:X-RW-Ts(秒级时间戳)、X-RW-Nonce(随机串)、X-RW-Sig(hex)
|
|
14
|
+
* WS 鉴权:首条消息 { type:'auth', ts, nonce, sig },签名串 "WS\nTS\nNONCE"
|
|
15
|
+
* WS 通道:/ws 控制台实时广播 + pty 终端输入上行;/worker/ws Worker 任务通知
|
|
16
|
+
* 与 pty 会话 IO 中继(session.output 下行广播、session.input 定向转发)。
|
|
17
|
+
* 有任务即推 task.available,领取仍走 HTTP claim,轮询兜底不丢任务
|
|
18
|
+
*
|
|
19
|
+
* 配置文件(--config 传入的 YAML 文件,唯一配置来源,不再读取环境变量;
|
|
20
|
+
* 字段说明与模板见包内 config.example.yaml):
|
|
21
|
+
* apiSecret 必填,两端共享的签名密钥(强随机 hex,见 README 快速开始)
|
|
22
|
+
* port 可选,默认 8787
|
|
23
|
+
* dataDir 可选,持久化目录,默认 ~/.kite/data;
|
|
24
|
+
* 相对路径相对启动工作目录解析,建议写绝对路径;
|
|
25
|
+
* 启动前校验可创建、可写,不满足直接报错退出
|
|
26
|
+
* maxTasks 可选,终态任务保留条数,默认 200
|
|
27
|
+
* staleRunningSec 可选,running 任务超过该时长且 Worker 离线则判失败,默认 600;
|
|
28
|
+
* pty 交互会话不受此限,由 ptyIdleSec 空闲回收
|
|
29
|
+
* sigWindowSec 可选,签名时间窗(秒),默认 300;两端时钟漂移大时可调宽
|
|
30
|
+
* ptyIdleSec 可选,pty 会话空闲(Worker 断开且无控制台输入)回收秒数,默认 90
|
|
31
|
+
* imWebhook 可选,钉钉群机器人 webhook 地址;配置后任务终态事件以 markdown 推送到群
|
|
32
|
+
* imSecret 可选,机器人“加签”安全设置的密钥(SEC 开头);关键词/白名单模式留空
|
|
33
|
+
* imNotifyEvents 可选,触发推送的事件集合(数组):done|failed|canceled,默认 [failed]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import http from 'node:http';
|
|
37
|
+
import crypto from 'node:crypto';
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { WebSocketServer } from 'ws';
|
|
42
|
+
import DingBot from 'dingbot';
|
|
43
|
+
import { loadServerConfig } from './config.js';
|
|
44
|
+
|
|
45
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
|
|
47
|
+
const {
|
|
48
|
+
configFile: CONFIG_FILE,
|
|
49
|
+
apiSecret: API_SECRET,
|
|
50
|
+
port: PORT,
|
|
51
|
+
dataDir: DATA_DIR,
|
|
52
|
+
maxTasks: MAX_TASKS,
|
|
53
|
+
staleRunningMs: STALE_RUNNING_MS,
|
|
54
|
+
sigWindowSec: SIG_WINDOW_SEC,
|
|
55
|
+
ptyIdleMs: PTY_IDLE_MS,
|
|
56
|
+
imWebhook: IM_WEBHOOK,
|
|
57
|
+
imSecret: IM_SECRET,
|
|
58
|
+
imNotifyEvents: IM_NOTIFY_EVENTS,
|
|
59
|
+
} = await loadServerConfig();
|
|
60
|
+
const WEB_DIR = path.join(__dirname, 'web');
|
|
61
|
+
const DB_FILE = path.join(DATA_DIR, 'tasks.json');
|
|
62
|
+
const MAX_LOG_LINES = 2000;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// 持久化:单文件 JSON,异步落盘(脏标记 + 节流 + 串行化,全程无同步 IO)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const state = { tasks: [], heartbeats: {} };
|
|
69
|
+
|
|
70
|
+
async function loadState() {
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = await fs.promises.readFile(DB_FILE, 'utf8');
|
|
74
|
+
} catch {
|
|
75
|
+
return; // 首次启动,文件尚不存在,从空状态开始
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
state.tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
80
|
+
state.heartbeats = parsed.heartbeats || {};
|
|
81
|
+
} catch {
|
|
82
|
+
// 文件损坏:先备份,避免被后续写盘静默覆盖丢失历史数据
|
|
83
|
+
console.warn('[server] tasks.json 已损坏,备份后从空状态启动');
|
|
84
|
+
await fs.promises.rename(DB_FILE, `${DB_FILE}.corrupt-${Date.now()}`).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 只保留最近 MAX_TASKS 个终态任务,进行中的不受影响,防止无限增长
|
|
89
|
+
function pruneFinishedTasks() {
|
|
90
|
+
const finished = state.tasks.filter((t) => ['done', 'failed', 'canceled'].includes(t.status));
|
|
91
|
+
if (finished.length > MAX_TASKS) {
|
|
92
|
+
const cutoff = finished
|
|
93
|
+
.sort((a, b) => b.finishedAt - a.finishedAt)
|
|
94
|
+
[MAX_TASKS - 1].finishedAt;
|
|
95
|
+
state.tasks = state.tasks.filter(
|
|
96
|
+
(t) => !['done', 'failed', 'canceled'].includes(t.status) || t.finishedAt >= cutoff,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 落盘策略:脏标记 + 300ms 节流合并高频写(心跳/日志上报),串行链防交叠,
|
|
102
|
+
// tmp + rename 原子替换。崩溃最多丢约 300ms 的变更,
|
|
103
|
+
// 卡住的 running 任务由心跳超时回收兜底,可接受。
|
|
104
|
+
let savePending = false;
|
|
105
|
+
let saveTimer = null;
|
|
106
|
+
let savingChain = Promise.resolve();
|
|
107
|
+
|
|
108
|
+
async function flushSave() {
|
|
109
|
+
if (!savePending) return;
|
|
110
|
+
savePending = false; // 先清脏标记再序列化快照;写失败会重新标脏重试
|
|
111
|
+
pruneFinishedTasks();
|
|
112
|
+
try {
|
|
113
|
+
const data = JSON.stringify(state, null, 2);
|
|
114
|
+
const tmp = DB_FILE + '.tmp';
|
|
115
|
+
await fs.promises.writeFile(tmp, data);
|
|
116
|
+
await fs.promises.rename(tmp, DB_FILE);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error(`[server] 落盘失败,下次变更时重试: ${err.message}`);
|
|
119
|
+
scheduleSave();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function scheduleSave() {
|
|
124
|
+
savePending = true;
|
|
125
|
+
if (saveTimer) return;
|
|
126
|
+
saveTimer = setTimeout(() => {
|
|
127
|
+
saveTimer = null;
|
|
128
|
+
savingChain = savingChain.then(flushSave);
|
|
129
|
+
}, 300).unref();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 退出前把待写变更落盘(systemd 停服走 SIGTERM)
|
|
133
|
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
134
|
+
process.on(sig, () => {
|
|
135
|
+
savingChain.then(flushSave).finally(() => process.exit(0));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Worker 崩溃/掉线后,回收卡死在 running 的任务。
|
|
140
|
+
// pty 交互会话例外:时长不可预期,由 PTY_IDLE_SEC 空闲回收(见 sweepPtyIdle)
|
|
141
|
+
function sweepStaleRunning() {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
for (const t of state.tasks) {
|
|
144
|
+
if (t.status !== 'running' || t.type === 'pty') continue;
|
|
145
|
+
const hb = t.workerId ? state.heartbeats[t.workerId] : null;
|
|
146
|
+
const workerAlive = hb && now - hb.at < 90 * 1000;
|
|
147
|
+
if (!workerAlive && t.startedAt && now - t.startedAt > STALE_RUNNING_MS) {
|
|
148
|
+
t.status = 'failed';
|
|
149
|
+
t.finishedAt = now;
|
|
150
|
+
t.result = { error: 'Worker 离线超时,任务被服务端回收' };
|
|
151
|
+
t.logs.push('[server] Worker 心跳超时,任务被标记为失败');
|
|
152
|
+
broadcast({ type: 'task.updated', task: taskSummary(t), result: t.result });
|
|
153
|
+
notifyIm(t, 'failed');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// dataDir 的存在性与可写性已由 config.js 启动校验保证
|
|
159
|
+
await loadState();
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// 工具函数
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
function timingSafeEqualStr(a, b) {
|
|
166
|
+
const ha = crypto.createHash('sha256').update(String(a)).digest();
|
|
167
|
+
const hb = crypto.createHash('sha256').update(String(b)).digest();
|
|
168
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function json(res, code, body) {
|
|
172
|
+
const data = JSON.stringify(body);
|
|
173
|
+
res.writeHead(code, {
|
|
174
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
175
|
+
'Cache-Control': 'no-store',
|
|
176
|
+
});
|
|
177
|
+
res.end(data);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// 签名鉴权:HMAC-SHA256(密钥, METHOD\nPATH\nTS\nNONCE\nSHA256(BODY))
|
|
182
|
+
// 密钥永不上网;时间窗 + nonce 缓存联合防重放。
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
const seenNonces = new Map(); // nonce -> 过期时间戳(ms)
|
|
186
|
+
|
|
187
|
+
function pruneNonces(now) {
|
|
188
|
+
if (seenNonces.size < 10000) return;
|
|
189
|
+
for (const [nonce, expiry] of seenNonces) {
|
|
190
|
+
if (expiry < now) seenNonces.delete(nonce);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 校验 ts/nonce/签名三元组;通过即消费掉该 nonce。
|
|
195
|
+
// signingParts 为参与签名的各行(不含密钥),body 为原始字节(可为空 Buffer)。
|
|
196
|
+
function verifySignature(signingParts, tsRaw, nonce, sig) {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
if (!tsRaw || !nonce || !sig) {
|
|
199
|
+
return { ok: false, reason: '缺少签名头 X-RW-Ts/X-RW-Nonce/X-RW-Sig' };
|
|
200
|
+
}
|
|
201
|
+
const ts = Number(tsRaw);
|
|
202
|
+
if (!Number.isFinite(ts)) return { ok: false, reason: `时间戳非法: ${String(tsRaw).slice(0, 32)}` };
|
|
203
|
+
const skewSec = Math.abs(now / 1000 - ts);
|
|
204
|
+
if (skewSec > SIG_WINDOW_SEC) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
reason: `时间戳超出 ±${SIG_WINDOW_SEC}s 窗口(偏差 ${Math.round(skewSec)}s;检查客户端时钟)`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const nonceStr = String(nonce);
|
|
211
|
+
if (nonceStr.length < 8 || nonceStr.length > 64) {
|
|
212
|
+
return { ok: false, reason: 'nonce 长度非法' };
|
|
213
|
+
}
|
|
214
|
+
if (seenNonces.has(nonceStr)) {
|
|
215
|
+
return { ok: false, reason: 'nonce 重复(疑似重放)' };
|
|
216
|
+
}
|
|
217
|
+
const signingString = signingParts.join('\n');
|
|
218
|
+
const expected = crypto.createHmac('sha256', API_SECRET).update(signingString).digest('hex');
|
|
219
|
+
if (!timingSafeEqualStr(String(sig), expected)) {
|
|
220
|
+
return { ok: false, reason: '签名不匹配(密钥错误或请求被篡改)' };
|
|
221
|
+
}
|
|
222
|
+
seenNonces.set(nonceStr, now + SIG_WINDOW_SEC * 1000);
|
|
223
|
+
pruneNonces(now);
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readRawBody(req, limit = 1024 * 1024) {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
let size = 0;
|
|
230
|
+
const chunks = [];
|
|
231
|
+
req.on('data', (chunk) => {
|
|
232
|
+
size += chunk.length;
|
|
233
|
+
if (size > limit) {
|
|
234
|
+
reject(new Error('body too large'));
|
|
235
|
+
req.destroy();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
chunks.push(chunk);
|
|
239
|
+
});
|
|
240
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
241
|
+
req.on('error', reject);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// REST 请求验签:签名覆盖方法、路径、时间戳、nonce 与请求体摘要。
|
|
246
|
+
// 注意:路径用含 query 的完整形式(与客户端签名方式一致,防止改写查询参数)。
|
|
247
|
+
async function verifyRestSignature(req, pathname) {
|
|
248
|
+
const rawBody = await readRawBody(req);
|
|
249
|
+
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 };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function parseBody(rawBody) {
|
|
260
|
+
if (!rawBody || !rawBody.length) return {};
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(rawBody.toString('utf8'));
|
|
263
|
+
} catch {
|
|
264
|
+
const err = new Error('invalid json');
|
|
265
|
+
err.statusCode = 400;
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function publicTask(task) {
|
|
271
|
+
// 不外泄内部字段的浅拷贝
|
|
272
|
+
return { ...task };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// WebSocket 实时推送:控制台通过 /ws 建立长连接,任务变更即时下发;
|
|
277
|
+
// Worker 通过 /worker/ws 建立长连接,仅接收任务到达通知(见下方 workerWss)。
|
|
278
|
+
// 鉴权走连接后的第一条消息(Token 不进 URL,不落日志)。
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
function taskSummary(t) {
|
|
282
|
+
return {
|
|
283
|
+
id: t.id,
|
|
284
|
+
type: t.type,
|
|
285
|
+
title: t.title,
|
|
286
|
+
status: t.status,
|
|
287
|
+
createdAt: t.createdAt,
|
|
288
|
+
startedAt: t.startedAt,
|
|
289
|
+
finishedAt: t.finishedAt,
|
|
290
|
+
workerId: t.workerId,
|
|
291
|
+
logCount: t.logs.length,
|
|
292
|
+
...shellPreview(t),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 控制台 shell 视图预览:命令回显行($ xxx)+ 末尾最多 5 行 stdout。
|
|
297
|
+
// [worker]/[server] 元信息行与空行不算 stdout;控制台默认只展示这些,
|
|
298
|
+
// 其余(元信息、结果 JSON、更早输出)点击展开才可见。
|
|
299
|
+
function shellPreview(t) {
|
|
300
|
+
let cmd = null;
|
|
301
|
+
let outLines = 0;
|
|
302
|
+
const tail = [];
|
|
303
|
+
for (const line of t.logs) {
|
|
304
|
+
if (line.startsWith('$ ')) { if (!cmd) cmd = line; continue; }
|
|
305
|
+
if (line.startsWith('[worker]') || line.startsWith('[server]') || line === '') continue;
|
|
306
|
+
outLines++;
|
|
307
|
+
tail.push(line);
|
|
308
|
+
if (tail.length > 5) tail.shift();
|
|
309
|
+
}
|
|
310
|
+
return { cmd, outLines, tail };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function workersSnapshot() {
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
return Object.entries(state.heartbeats).map(([id, hb]) => ({
|
|
316
|
+
workerId: id,
|
|
317
|
+
hostname: hb.hostname,
|
|
318
|
+
lastSeenAt: hb.at,
|
|
319
|
+
online: now - hb.at < 90 * 1000,
|
|
320
|
+
agents: hb.agents || [],
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 已鉴权连接集合(未通过鉴权的 socket 不会进入此集合)
|
|
325
|
+
const wsClients = new Set();
|
|
326
|
+
|
|
327
|
+
function broadcast(msg) {
|
|
328
|
+
if (!wsClients.size) return;
|
|
329
|
+
const data = JSON.stringify(msg);
|
|
330
|
+
for (const ws of wsClients) ws.send(data);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 已鉴权的 Worker 通知连接(见下方 /worker/ws)。通知只是加速:
|
|
334
|
+
// Worker 收到后仍走 HTTP claim 原子领取,断线期间由轮询兜底
|
|
335
|
+
const wsWorkers = new Set();
|
|
336
|
+
|
|
337
|
+
function notifyWorkers() {
|
|
338
|
+
if (!wsWorkers.size) return;
|
|
339
|
+
const data = JSON.stringify({ type: 'task.available' });
|
|
340
|
+
for (const ws of wsWorkers) ws.send(data);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// IM 群通知:任务终态事件推送钉钉群机器人(markdown),走 dingbot SDK;
|
|
345
|
+
// 机器人启用“加签”安全设置时由 IM_SECRET 自动完成 timestamp+sign 拼接。
|
|
346
|
+
// 未配置 IM_WEBHOOK 完全静默;发送失败只记日志,绝不抛出影响主链路。
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
const IM_STATUS_META = {
|
|
350
|
+
done: { icon: '✅', label: '完成' },
|
|
351
|
+
failed: { icon: '❌', label: '失败' },
|
|
352
|
+
canceled: { icon: '🚫', label: '已取消' },
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const IM_BOT = IM_WEBHOOK ? new DingBot(IM_WEBHOOK, { timeout: 10_000 }, IM_SECRET || undefined) : null;
|
|
356
|
+
|
|
357
|
+
// 按任务类型提取推送摘要:命令/脚本/prompt/留言,pty 只有项目目录
|
|
358
|
+
function imPayloadBrief(task) {
|
|
359
|
+
const p = task.payload || {};
|
|
360
|
+
switch (task.type) {
|
|
361
|
+
case 'shell':
|
|
362
|
+
return p.command ? `$ ${String(p.command).trim()}` : '';
|
|
363
|
+
case 'script': {
|
|
364
|
+
const args = Array.isArray(p.args) ? p.args.map(String) : [];
|
|
365
|
+
return p.script ? `${p.script}${args.length ? ` ${args.join(' ')}` : ''}` : '';
|
|
366
|
+
}
|
|
367
|
+
case 'agent':
|
|
368
|
+
return [p.agent ? `Agent: ${p.agent}` : '', p.project ? `项目: ${p.project}` : '', String(p.prompt || '').trim()]
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.join(' | ');
|
|
371
|
+
case 'chat':
|
|
372
|
+
return String(p.message || '').trim();
|
|
373
|
+
case 'pty':
|
|
374
|
+
return p.project ? `交互终端(项目:${p.project})` : '交互终端';
|
|
375
|
+
default:
|
|
376
|
+
return '';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function notifyIm(task, event) {
|
|
381
|
+
if (!IM_BOT || !IM_NOTIFY_EVENTS.has(event)) return;
|
|
382
|
+
const meta = IM_STATUS_META[event] || { icon: 'ℹ️', label: event };
|
|
383
|
+
const fmtTime = (ms) => new Date(ms).toLocaleString('zh-CN', { hour12: false });
|
|
384
|
+
const fmtDur = (ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)}分钟` : `${Math.round(ms / 1000)}s`);
|
|
385
|
+
// 超长内容中间截断,保留首尾
|
|
386
|
+
const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 8)} … ${s.slice(-4)}` : s);
|
|
387
|
+
|
|
388
|
+
const lines = [
|
|
389
|
+
`### ${meta.icon} 任务${meta.label}:${task.title || task.type}`,
|
|
390
|
+
`- 类型:${task.type}`,
|
|
391
|
+
];
|
|
392
|
+
const hb = task.workerId ? state.heartbeats[task.workerId] : null;
|
|
393
|
+
if (task.workerId) {
|
|
394
|
+
lines.push(`- Worker:${hb?.hostname ? `${hb.hostname}(${task.workerId})` : task.workerId}`);
|
|
395
|
+
}
|
|
396
|
+
const brief = imPayloadBrief(task);
|
|
397
|
+
if (brief) lines.push(`- 内容:${truncate(brief, 200)}`);
|
|
398
|
+
if (task.createdAt) lines.push(`- 创建:${fmtTime(task.createdAt)}`);
|
|
399
|
+
if (task.startedAt) lines.push(`- 开始:${fmtTime(task.startedAt)}`);
|
|
400
|
+
if (task.finishedAt) lines.push(`- 结束:${fmtTime(task.finishedAt)}`);
|
|
401
|
+
if (task.startedAt && task.finishedAt) {
|
|
402
|
+
lines.push(`- 耗时:${fmtDur(task.finishedAt - task.startedAt)}`);
|
|
403
|
+
}
|
|
404
|
+
if (task.result) {
|
|
405
|
+
const detail = String(task.result.error || JSON.stringify(task.result)).slice(0, 300);
|
|
406
|
+
if (detail !== '{}') lines.push(`- 结果:${detail}`);
|
|
407
|
+
}
|
|
408
|
+
// 日志末尾几行,失败时可直接看到报错上下文
|
|
409
|
+
const tail = (task.logs || [])
|
|
410
|
+
.filter((l) => !/^\[(server|worker)\]/.test(l) && l.trim())
|
|
411
|
+
.slice(-3)
|
|
412
|
+
.map((l) => truncate(String(l), 150));
|
|
413
|
+
if (tail.length) lines.push('**日志末尾:**', ...tail.map((l) => `> ${l}`));
|
|
414
|
+
lines.push(`任务 ID:\`${task.id}\``);
|
|
415
|
+
// SDK 内部校验钉钉返回的 errcode(关键词/加签拦截、限流等业务错误)并抛错
|
|
416
|
+
IM_BOT.markdown(`kite 任务${meta.label}`, lines.join('\n'))
|
|
417
|
+
.catch((err) => console.error(`[server] IM 推送失败(${task.id}):${err.message}`));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// pty 会话中继:taskId -> { ws, tail, lastActiveAt }
|
|
422
|
+
// Worker 上行 session.output -> 追加回看缓冲并广播控制台;
|
|
423
|
+
// 控制台下行 session.input/resize/close -> 定向转发给持有会话的 Worker。
|
|
424
|
+
// 回看缓冲只留末尾 16KB,手机切后台重连终端不至于白屏。
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
const ptySessions = new Map();
|
|
428
|
+
const PTY_TAIL_BYTES = 16 * 1024;
|
|
429
|
+
|
|
430
|
+
function wsSendJson(ws, msg) {
|
|
431
|
+
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 关闭会话并(可选)把 running 的 pty 任务落为终态;reason 为空表示 Worker
|
|
435
|
+
// 正常上报退出,任务终态由 Worker 的 finish 上报负责,这里只清会话
|
|
436
|
+
function closePtySession(taskId, { status, error, reason }) {
|
|
437
|
+
const s = ptySessions.get(taskId);
|
|
438
|
+
if (s) ptySessions.delete(taskId);
|
|
439
|
+
if (status) {
|
|
440
|
+
const task = state.tasks.find((t) => t.id === taskId);
|
|
441
|
+
if (task && task.status === 'running') {
|
|
442
|
+
task.status = status;
|
|
443
|
+
task.finishedAt = Date.now();
|
|
444
|
+
task.result = { error };
|
|
445
|
+
if (reason) task.logs.push(reason);
|
|
446
|
+
scheduleSave();
|
|
447
|
+
broadcast({ type: 'task.updated', task: taskSummary(task), result: task.result });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
broadcast({ type: 'pty.exited', id: taskId, reason: error || 'closed' });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Worker 通道断开或会话长时间无活动(Worker 掉线且无控制台输入)时回收,
|
|
454
|
+
// 防止忘关的终端在 Worker 上常驻
|
|
455
|
+
function sweepPtyIdle() {
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
for (const [taskId, s] of ptySessions) {
|
|
458
|
+
const workerGone = !s.ws || s.ws.readyState !== s.ws.OPEN;
|
|
459
|
+
if (workerGone && now - s.lastActiveAt > PTY_IDLE_MS) {
|
|
460
|
+
closePtySession(taskId, {
|
|
461
|
+
status: 'failed',
|
|
462
|
+
error: '终端会话空闲超时,已被服务端回收',
|
|
463
|
+
reason: '[server] 终端会话空闲超时(Worker 断开),已回收',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// API 路由
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
async function handleApi(req, res, pathname, rawBody) {
|
|
474
|
+
sweepStaleRunning();
|
|
475
|
+
const body = parseBody(rawBody);
|
|
476
|
+
|
|
477
|
+
// --- Worker:领取最老的 pending 任务(原子置为 running)---
|
|
478
|
+
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');
|
|
481
|
+
if (!task) return json(res, 200, { task: null });
|
|
482
|
+
task.status = 'running';
|
|
483
|
+
task.workerId = workerId;
|
|
484
|
+
task.startedAt = Date.now();
|
|
485
|
+
scheduleSave();
|
|
486
|
+
broadcast({ type: 'task.updated', task: taskSummary(task) });
|
|
487
|
+
return json(res, 200, { task });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// --- Worker:心跳 ---
|
|
491
|
+
if (req.method === 'POST' && pathname === '/api/worker/heartbeat') {
|
|
492
|
+
const workerId = String(body.workerId || 'unknown').slice(0, 128);
|
|
493
|
+
state.heartbeats[workerId] = {
|
|
494
|
+
at: Date.now(),
|
|
495
|
+
hostname: String(body.hostname || '').slice(0, 128),
|
|
496
|
+
node: String(body.node || '').slice(0, 64),
|
|
497
|
+
// Worker 配置的可用 Agent 列表(控制台据此渲染下拉选择)
|
|
498
|
+
agents: Array.isArray(body.agents)
|
|
499
|
+
? body.agents.map((a) => String(a).slice(0, 64)).slice(0, 32)
|
|
500
|
+
: [],
|
|
501
|
+
};
|
|
502
|
+
scheduleSave();
|
|
503
|
+
broadcast({ type: 'workers', workers: workersSnapshot() });
|
|
504
|
+
return json(res, 200, { ok: true });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// --- Worker / 控制台:查询 Worker 在线状态 ---
|
|
508
|
+
if (req.method === 'GET' && pathname === '/api/worker/status') {
|
|
509
|
+
return json(res, 200, { workers: workersSnapshot() });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// --- 创建任务 ---
|
|
513
|
+
if (req.method === 'POST' && pathname === '/api/tasks') {
|
|
514
|
+
const type = String(body.type || '');
|
|
515
|
+
if (!['script', 'shell', 'agent', 'chat', 'pty'].includes(type)) {
|
|
516
|
+
return json(res, 400, { error: 'type 必须是 script|shell|agent|chat|pty' });
|
|
517
|
+
}
|
|
518
|
+
const task = {
|
|
519
|
+
id: crypto.randomUUID(),
|
|
520
|
+
type,
|
|
521
|
+
title: String(body.title || '').slice(0, 200),
|
|
522
|
+
payload: body.payload || {},
|
|
523
|
+
status: 'pending',
|
|
524
|
+
logs: [],
|
|
525
|
+
result: null,
|
|
526
|
+
createdAt: Date.now(),
|
|
527
|
+
startedAt: null,
|
|
528
|
+
finishedAt: null,
|
|
529
|
+
workerId: null,
|
|
530
|
+
};
|
|
531
|
+
state.tasks.push(task);
|
|
532
|
+
scheduleSave();
|
|
533
|
+
broadcast({ type: 'task.created', task: taskSummary(task) });
|
|
534
|
+
notifyWorkers(); // 任务到达即推送,Worker 立即领取,不再等轮询周期
|
|
535
|
+
return json(res, 201, publicTask(task));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- 任务列表 ---
|
|
539
|
+
if (req.method === 'GET' && pathname === '/api/tasks') {
|
|
540
|
+
const url = new URL(req.url, 'http://localhost');
|
|
541
|
+
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 50, 200));
|
|
542
|
+
const list = [...state.tasks]
|
|
543
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
544
|
+
.slice(0, limit)
|
|
545
|
+
.map(taskSummary);
|
|
546
|
+
return json(res, 200, { tasks: list });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// --- 单个任务详情(含日志)---
|
|
550
|
+
const taskMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})$/);
|
|
551
|
+
if (req.method === 'GET' && taskMatch) {
|
|
552
|
+
const task = state.tasks.find((t) => t.id === taskMatch[1]);
|
|
553
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
554
|
+
return json(res, 200, publicTask(task));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// --- 追加日志(Worker)---
|
|
558
|
+
const logMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/logs$/);
|
|
559
|
+
if (req.method === 'POST' && logMatch) {
|
|
560
|
+
const task = state.tasks.find((t) => t.id === logMatch[1]);
|
|
561
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
562
|
+
const lines = Array.isArray(body.lines) ? body.lines : [];
|
|
563
|
+
for (const line of lines) {
|
|
564
|
+
task.logs.push(String(line).slice(0, 4000));
|
|
565
|
+
}
|
|
566
|
+
if (task.logs.length > MAX_LOG_LINES) {
|
|
567
|
+
task.logs.splice(0, task.logs.length - MAX_LOG_LINES);
|
|
568
|
+
}
|
|
569
|
+
scheduleSave();
|
|
570
|
+
broadcast({ type: 'task.logs', id: task.id, lines });
|
|
571
|
+
return json(res, 200, { ok: true, logCount: task.logs.length });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- 结束任务(Worker)---
|
|
575
|
+
const finishMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/finish$/);
|
|
576
|
+
if (req.method === 'POST' && finishMatch) {
|
|
577
|
+
const task = state.tasks.find((t) => t.id === finishMatch[1]);
|
|
578
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
579
|
+
task.status = body.status === 'done' ? 'done' : 'failed';
|
|
580
|
+
task.result = body.result ?? null;
|
|
581
|
+
task.finishedAt = Date.now();
|
|
582
|
+
scheduleSave();
|
|
583
|
+
broadcast({ type: 'task.updated', task: taskSummary(task), result: task.result });
|
|
584
|
+
notifyIm(task, task.status);
|
|
585
|
+
return json(res, 200, publicTask(task));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- 取消任务(控制台;仅对 pending 生效)---
|
|
589
|
+
const cancelMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/cancel$/);
|
|
590
|
+
if (req.method === 'POST' && cancelMatch) {
|
|
591
|
+
const task = state.tasks.find((t) => t.id === cancelMatch[1]);
|
|
592
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
593
|
+
if (task.status !== 'pending') {
|
|
594
|
+
return json(res, 409, { error: `只能取消 pending 任务,当前状态:${task.status}` });
|
|
595
|
+
}
|
|
596
|
+
task.status = 'canceled';
|
|
597
|
+
task.finishedAt = Date.now();
|
|
598
|
+
scheduleSave();
|
|
599
|
+
broadcast({ type: 'task.updated', task: taskSummary(task) });
|
|
600
|
+
notifyIm(task, 'canceled');
|
|
601
|
+
return json(res, 200, publicTask(task));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// --- 关闭交互终端(控制台):通知持有会话的 Worker 杀掉 PTY,
|
|
605
|
+
// 任务终态仍由 Worker 的 finish 上报闭环 ---
|
|
606
|
+
const killMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/kill$/);
|
|
607
|
+
if (req.method === 'POST' && killMatch) {
|
|
608
|
+
const s = ptySessions.get(killMatch[1]);
|
|
609
|
+
if (!s) return json(res, 404, { error: '没有活跃的终端会话' });
|
|
610
|
+
wsSendJson(s.ws, { type: 'session.close', taskId: killMatch[1] });
|
|
611
|
+
return json(res, 200, { ok: true });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return json(res, 404, { error: 'not found' });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// 静态文件(手机端 Web 控制台)
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
const MIME = {
|
|
622
|
+
'.html': 'text/html; charset=utf-8',
|
|
623
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
624
|
+
'.css': 'text/css; charset=utf-8',
|
|
625
|
+
'.svg': 'image/svg+xml',
|
|
626
|
+
'.png': 'image/png',
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
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)) {
|
|
633
|
+
res.writeHead(403);
|
|
634
|
+
return res.end('forbidden');
|
|
635
|
+
}
|
|
636
|
+
fs.readFile(filePath, (err, data) => {
|
|
637
|
+
if (err) {
|
|
638
|
+
res.writeHead(404);
|
|
639
|
+
return res.end('not found');
|
|
640
|
+
}
|
|
641
|
+
res.writeHead(200, {
|
|
642
|
+
'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream',
|
|
643
|
+
'Cache-Control': 'no-store',
|
|
644
|
+
});
|
|
645
|
+
res.end(data);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// HTTP 入口
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
const server = http.createServer(async (req, res) => {
|
|
654
|
+
try {
|
|
655
|
+
const url = new URL(req.url, 'http://localhost');
|
|
656
|
+
const pathname = url.pathname;
|
|
657
|
+
|
|
658
|
+
if (pathname.startsWith('/api/')) {
|
|
659
|
+
// 先验签(读原始 body 计算摘要),通过后才进入业务路由
|
|
660
|
+
const auth = await verifyRestSignature(req, pathname);
|
|
661
|
+
if (!auth.ok) {
|
|
662
|
+
console.warn(
|
|
663
|
+
`[server] 拒绝请求 ${req.method} ${pathname},来源 ${req.socket.remoteAddress}:${auth.reason}`,
|
|
664
|
+
);
|
|
665
|
+
return json(res, 401, { error: 'unauthorized' });
|
|
666
|
+
}
|
|
667
|
+
return await handleApi(req, res, pathname, auth.rawBody);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (req.method === 'GET') return serveStatic(req, res, pathname);
|
|
671
|
+
|
|
672
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
673
|
+
} catch (err) {
|
|
674
|
+
return json(res, err.statusCode || (err.message === 'invalid json' ? 400 : 500), {
|
|
675
|
+
error: err.message || 'internal error',
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
// WebSocket 连接管理
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
685
|
+
|
|
686
|
+
wss.on('connection', (ws, req) => {
|
|
687
|
+
const remote = req.socket.remoteAddress;
|
|
688
|
+
ws.isAuthed = false;
|
|
689
|
+
ws.isAlive = true;
|
|
690
|
+
|
|
691
|
+
// 连接后 5 秒内必须完成鉴权,否则关闭,防止裸连接占资源
|
|
692
|
+
const authTimer = setTimeout(() => ws.close(4001, 'auth timeout'), 5000);
|
|
693
|
+
|
|
694
|
+
ws.on('message', (raw) => {
|
|
695
|
+
if (!ws.isAuthed) {
|
|
696
|
+
let msg = null;
|
|
697
|
+
try {
|
|
698
|
+
msg = JSON.parse(raw.toString());
|
|
699
|
+
} catch {
|
|
700
|
+
ws.close(4002, 'bad auth message');
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (!msg || msg.type !== 'auth') {
|
|
704
|
+
ws.close(4002, 'bad auth message');
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
// 签名鉴权:sig = HMAC(密钥, "WS\nTS\nNONCE"),同样走时间窗 + nonce 防重放
|
|
708
|
+
const result = verifySignature(
|
|
709
|
+
['WS', String(msg.ts), String(msg.nonce)],
|
|
710
|
+
msg.ts,
|
|
711
|
+
msg.nonce,
|
|
712
|
+
msg.sig,
|
|
713
|
+
);
|
|
714
|
+
if (!result.ok) {
|
|
715
|
+
console.warn(`[server] 拒绝 WS 连接,来源 ${remote}:${result.reason}`);
|
|
716
|
+
ws.close(4003, 'auth failed');
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
clearTimeout(authTimer);
|
|
720
|
+
ws.isAuthed = true;
|
|
721
|
+
wsClients.add(ws);
|
|
722
|
+
// 鉴权通过立即下发全量快照,之后的增量靠 broadcast
|
|
723
|
+
ws.send(
|
|
724
|
+
JSON.stringify({
|
|
725
|
+
type: 'hello',
|
|
726
|
+
tasks: [...state.tasks].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(taskSummary),
|
|
727
|
+
workers: workersSnapshot(),
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 鉴权后只受理 pty 终端交互消息,其余一律忽略
|
|
734
|
+
let msg = null;
|
|
735
|
+
try {
|
|
736
|
+
msg = JSON.parse(raw.toString());
|
|
737
|
+
} catch {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (!msg || typeof msg.taskId !== 'string') return;
|
|
741
|
+
const taskId = msg.taskId.slice(0, 64);
|
|
742
|
+
|
|
743
|
+
if (msg.type === 'pty.attach') {
|
|
744
|
+
// 打开终端:先补回看缓冲,再告知 Worker 侧会话是否仍活
|
|
745
|
+
const s = ptySessions.get(taskId);
|
|
746
|
+
wsSendJson(ws, {
|
|
747
|
+
type: 'pty.attach',
|
|
748
|
+
id: taskId,
|
|
749
|
+
tail: s ? s.tail : '',
|
|
750
|
+
live: Boolean(s && s.ws && s.ws.readyState === s.ws.OPEN),
|
|
751
|
+
});
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (msg.type === 'session.input') {
|
|
755
|
+
const s = ptySessions.get(taskId);
|
|
756
|
+
if (!s) return wsSendJson(ws, { type: 'pty.exited', id: taskId, reason: '会话已结束' });
|
|
757
|
+
s.lastActiveAt = Date.now();
|
|
758
|
+
wsSendJson(s.ws, { type: 'session.input', taskId, data: String(msg.data || '').slice(0, 65536) });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (msg.type === 'session.resize') {
|
|
762
|
+
const s = ptySessions.get(taskId);
|
|
763
|
+
if (!s) return;
|
|
764
|
+
wsSendJson(s.ws, {
|
|
765
|
+
type: 'session.resize',
|
|
766
|
+
taskId,
|
|
767
|
+
cols: Number(msg.cols) || 80,
|
|
768
|
+
rows: Number(msg.rows) || 24,
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (msg.type === 'session.close') {
|
|
773
|
+
const s = ptySessions.get(taskId);
|
|
774
|
+
if (s) wsSendJson(s.ws, { type: 'session.close', taskId });
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
ws.on('pong', () => {
|
|
779
|
+
ws.isAlive = true;
|
|
780
|
+
});
|
|
781
|
+
ws.on('close', () => {
|
|
782
|
+
clearTimeout(authTimer);
|
|
783
|
+
wsClients.delete(ws);
|
|
784
|
+
});
|
|
785
|
+
ws.on('error', () => {});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// --- Worker 通道 /worker/ws:鉴权方式同 /ws;鉴权后下发 task.available,
|
|
789
|
+
// 并承载 pty 会话上行消息(session.started/output/exited)---
|
|
790
|
+
const workerWss = new WebSocketServer({ noServer: true });
|
|
791
|
+
|
|
792
|
+
workerWss.on('connection', (ws, req) => {
|
|
793
|
+
const remote = req.socket.remoteAddress;
|
|
794
|
+
ws.isAuthed = false;
|
|
795
|
+
ws.isAlive = true;
|
|
796
|
+
|
|
797
|
+
const authTimer = setTimeout(() => ws.close(4001, 'auth timeout'), 5000);
|
|
798
|
+
|
|
799
|
+
ws.on('message', (raw) => {
|
|
800
|
+
if (!ws.isAuthed) {
|
|
801
|
+
let msg = null;
|
|
802
|
+
try {
|
|
803
|
+
msg = JSON.parse(raw.toString());
|
|
804
|
+
} catch {
|
|
805
|
+
ws.close(4002, 'bad auth message');
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (!msg || msg.type !== 'auth') {
|
|
809
|
+
ws.close(4002, 'bad auth message');
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const result = verifySignature(
|
|
813
|
+
['WS', String(msg.ts), String(msg.nonce)],
|
|
814
|
+
msg.ts,
|
|
815
|
+
msg.nonce,
|
|
816
|
+
msg.sig,
|
|
817
|
+
);
|
|
818
|
+
if (!result.ok) {
|
|
819
|
+
console.warn(`[server] 拒绝 Worker WS 连接,来源 ${remote}:${result.reason}`);
|
|
820
|
+
ws.close(4003, 'auth failed');
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
clearTimeout(authTimer);
|
|
824
|
+
ws.isAuthed = true;
|
|
825
|
+
ws.workerId = String(msg.workerId || 'unknown').slice(0, 128);
|
|
826
|
+
wsWorkers.add(ws);
|
|
827
|
+
console.log(`[server] Worker ${ws.workerId} 通道已连接(来源 ${remote})`);
|
|
828
|
+
// 断线窗口内可能已有积压任务,连接建立即补一次通知
|
|
829
|
+
if (state.tasks.some((t) => t.status === 'pending')) {
|
|
830
|
+
ws.send(JSON.stringify({ type: 'task.available' }));
|
|
831
|
+
}
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// --- pty 会话上行消息 ---
|
|
836
|
+
let msg = null;
|
|
837
|
+
try {
|
|
838
|
+
msg = JSON.parse(raw.toString());
|
|
839
|
+
} catch {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (!msg || typeof msg.taskId !== 'string') return;
|
|
843
|
+
const taskId = msg.taskId.slice(0, 64);
|
|
844
|
+
const task = state.tasks.find((t) => t.id === taskId);
|
|
845
|
+
|
|
846
|
+
if (msg.type === 'session.started') {
|
|
847
|
+
// 会话注册(含 Worker 通道重连后的重新挂载);只认该 Worker 自己领取的任务
|
|
848
|
+
if (!task || task.type !== 'pty' || task.workerId !== ws.workerId) return;
|
|
849
|
+
const prev = ptySessions.get(taskId);
|
|
850
|
+
ptySessions.set(taskId, {
|
|
851
|
+
ws,
|
|
852
|
+
tail: prev ? prev.tail : '',
|
|
853
|
+
lastActiveAt: Date.now(),
|
|
854
|
+
});
|
|
855
|
+
broadcast({ type: 'pty.started', id: taskId, pid: Number(msg.pid) || 0 });
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (msg.type === 'session.output') {
|
|
859
|
+
const s = ptySessions.get(taskId);
|
|
860
|
+
if (!s) return;
|
|
861
|
+
const data = String(msg.data || '');
|
|
862
|
+
s.lastActiveAt = Date.now();
|
|
863
|
+
s.tail = (s.tail + data).slice(-PTY_TAIL_BYTES); // 回看缓冲,重连可重放
|
|
864
|
+
broadcast({ type: 'pty.data', id: taskId, data });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (msg.type === 'session.exited') {
|
|
868
|
+
// 只清会话映射;任务终态由 Worker 随后的 HTTP finish 上报负责
|
|
869
|
+
closePtySession(taskId, {});
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
ws.on('pong', () => {
|
|
874
|
+
ws.isAlive = true;
|
|
875
|
+
});
|
|
876
|
+
ws.on('close', () => {
|
|
877
|
+
clearTimeout(authTimer);
|
|
878
|
+
wsWorkers.delete(ws);
|
|
879
|
+
// 该连接持有的会话置为游离:PTY 在 Worker 上还活着,重连后会重新挂载;
|
|
880
|
+
// 超过 PTY_IDLE_SEC 未重连则由 sweepPtyIdle 判失败
|
|
881
|
+
for (const [, s] of ptySessions) {
|
|
882
|
+
if (s.ws === ws) s.ws = null;
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
ws.on('error', () => {});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// 两条通道共用同一 HTTP server,按路径分发 upgrade(path 模式的多个
|
|
889
|
+
// WebSocketServer 实例会在 upgrade 事件上互相干扰,故用 noServer 手动路由)
|
|
890
|
+
server.on('upgrade', (req, socket, head) => {
|
|
891
|
+
const pathname = new URL(req.url, 'http://localhost').pathname;
|
|
892
|
+
if (pathname === '/ws') {
|
|
893
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
894
|
+
} else if (pathname === '/worker/ws') {
|
|
895
|
+
workerWss.handleUpgrade(req, socket, head, (ws) => workerWss.emit('connection', ws, req));
|
|
896
|
+
} else {
|
|
897
|
+
socket.destroy();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
// 心跳探活:揪出半开连接(手机锁屏/断网后 TCP 可能不触发 close);
|
|
902
|
+
// 顺带回收空闲的 pty 会话
|
|
903
|
+
setInterval(() => {
|
|
904
|
+
for (const ws of [...wss.clients, ...workerWss.clients]) {
|
|
905
|
+
if (!ws.isAlive) {
|
|
906
|
+
ws.terminate();
|
|
907
|
+
wsClients.delete(ws);
|
|
908
|
+
wsWorkers.delete(ws);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
ws.isAlive = false;
|
|
912
|
+
ws.ping();
|
|
913
|
+
}
|
|
914
|
+
sweepPtyIdle();
|
|
915
|
+
}, 30 * 1000).unref();
|
|
916
|
+
|
|
917
|
+
server.listen(PORT, () => {
|
|
918
|
+
console.log(`[server] kite API listening on :${PORT}`);
|
|
919
|
+
console.log(`[server] config file: ${CONFIG_FILE}`);
|
|
920
|
+
console.log(`[server] data dir: ${DATA_DIR}`);
|
|
921
|
+
});
|