@jacksontian/kite-server 0.4.0 → 0.5.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/README.md +11 -11
- package/bin/server.js +8 -8
- package/config.example.yaml +5 -5
- package/lib/api.js +121 -81
- package/lib/auth.js +1 -1
- package/lib/broadcast.js +3 -3
- package/lib/config.js +2 -2
- package/lib/im.js +23 -23
- package/lib/logger.js +1 -1
- package/lib/pty.js +16 -16
- package/lib/static.js +5 -10
- package/lib/store.js +31 -21
- package/lib/summary.js +4 -2
- package/lib/util.js +5 -5
- package/lib/websocket.js +41 -35
- package/package.json +2 -2
- package/web/app.js +1107 -0
- package/web/favicon.svg +18 -0
- package/web/index.html +143 -986
- package/web/style.css +486 -0
- package/web/pc.html +0 -1104
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# @jacksontian/kite-server
|
|
2
2
|
|
|
3
3
|
[kite](https://github.com/jacksontian/remote-work) 的云端信箱组件:部署在你的云服务器(VPS)上,
|
|
4
|
-
提供 HMAC
|
|
4
|
+
提供 HMAC 签名鉴权的会话队列 API、WebSocket 实时推送和 Web 控制台(手机/PC 双版)。
|
|
5
5
|
|
|
6
6
|
整体架构、端到端部署流程见仓库根 README;本文只讲 server 这一侧。
|
|
7
7
|
|
|
8
8
|
## 职责
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
- 通过 `/worker/ws` 出站长连接向 Worker
|
|
12
|
-
- 托管 Web 控制台(`web/`
|
|
13
|
-
|
|
14
|
-
-
|
|
10
|
+
- 接收控制台提交的会话,持久化到 `dataDir`(会话队列 / 日志 / 结果)
|
|
11
|
+
- 通过 `/worker/ws` 出站长连接向 Worker 即时推送新会话,Worker 不监听任何端口
|
|
12
|
+
- 托管 Web 控制台(`web/` 随包分发),浏览器打开即用;单页应用(`index.html` 一份),
|
|
13
|
+
手机/PC 自适应同一套界面
|
|
14
|
+
- 会话终态可选推送到钉钉群机器人(`imWebhook`)
|
|
15
15
|
|
|
16
16
|
## 安装与启动
|
|
17
17
|
|
|
@@ -48,8 +48,8 @@ kites start # 前台启动服务;常驻托管交给 systemd(见下文 depl
|
|
|
48
48
|
| `logFile` | 程序自写日志文件,默认 `~/.kites/logs/server.log`;超 10MB 轮转并逐代顺延(`.1` 最新),stdout 输出不受影响 |
|
|
49
49
|
| `logLevel` | 日志落盘最低级别(`debug`\|`info`\|`warn`\|`error`),默认 `info`;只过滤落盘,前台输出不受影响 |
|
|
50
50
|
| `logKeep` | 轮转保留代数(`server.log.1` … `.N`),默认 5,超出自动删除最旧一代 |
|
|
51
|
-
| `
|
|
52
|
-
| `staleRunningSec` | running
|
|
51
|
+
| `maxSessions` | 终态会话最多保留条数 |
|
|
52
|
+
| `staleRunningSec` | running 会话超时回收秒数(Worker 离线时判失败,pty 会话不受此限) |
|
|
53
53
|
| `sigWindowSec` | 签名时间窗(秒),两端时钟漂移大时可调宽 |
|
|
54
54
|
| `ptyIdleSec` | pty 会话空闲回收秒数 |
|
|
55
55
|
| `imWebhook` / `imSecret` / `imNotifyEvents` | 钉钉群机器人通知,留空则完全静默 |
|
|
@@ -64,9 +64,9 @@ conf/data/logs 布局);生产托管以 `www-data` 运行时即 `/var/www/.ki
|
|
|
64
64
|
├── conf/
|
|
65
65
|
│ └── server.yaml # 配置(默认读这里,可用 --config 指定别处;模板见包内 config.example.yaml)
|
|
66
66
|
├── data/ # dataDir:持久化目录
|
|
67
|
-
│ ├──
|
|
68
|
-
│ ├──
|
|
69
|
-
│ └──
|
|
67
|
+
│ ├── sessions.json # 会话队列 + 日志 + 结果(0600,含全部命令与输出,勿外传)
|
|
68
|
+
│ ├── sessions.json.tmp # 落盘中转文件(写完整后 rename 原子替换,正常情况不会残留)
|
|
69
|
+
│ └── sessions.json.corrupt-<ts> # sessions.json 解析失败时的自动备份(仅在损坏时出现)
|
|
70
70
|
└── logs/
|
|
71
71
|
├── server.log # logFile:程序自写日志(每条一行:时间/级别/pid/内容)
|
|
72
72
|
└── server.log.1 … .N # 轮转历史(超 10MB 顺延一代,保留 logKeep 代,默认 5)
|
package/bin/server.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* kite 云端信箱 API
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 职责:签名鉴权、会话信箱(手机写入 / Worker 领取)、日志与结果收集、
|
|
6
6
|
* Worker 心跳、托管手机端 Web 控制台。
|
|
7
7
|
*
|
|
8
8
|
* 模块划分(lib/):持久化 store、签名鉴权 auth、API 路由 api、IM 通知 im、
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* 签名串:方法、路径、时间戳、nonce、请求体 SHA256,五行以换行符连接
|
|
18
18
|
* 请求头:X-RW-Ts(秒级时间戳)、X-RW-Nonce(随机串)、X-RW-Sig(hex)
|
|
19
19
|
* WS 鉴权:首条消息 { type:'auth', ts, nonce, sig },签名串 "WS\nTS\nNONCE"
|
|
20
|
-
* WS 通道:/ws 控制台实时广播 + pty 终端输入上行;/worker/ws Worker
|
|
20
|
+
* WS 通道:/ws 控制台实时广播 + pty 终端输入上行;/worker/ws Worker 会话通知
|
|
21
21
|
* 与 pty 会话 IO 中继(session.output 下行广播、session.input 定向转发)。
|
|
22
|
-
*
|
|
22
|
+
* 有会话即推 session.available,领取仍走 HTTP claim,轮询兜底不丢会话
|
|
23
23
|
*
|
|
24
24
|
* 配置文件(--config 传入的 YAML 文件,唯一配置来源,不再读取环境变量;
|
|
25
25
|
* 字段说明与模板见包内 config.example.yaml):
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
* logLevel 可选,日志落盘最低级别(debug|info|warn|error),默认 info;
|
|
41
41
|
* 只过滤落盘,前台输出不受影响
|
|
42
42
|
* logKeep 可选,轮转保留代数(server.log.1 … .N),默认 5
|
|
43
|
-
*
|
|
44
|
-
* staleRunningSec 可选,running
|
|
43
|
+
* maxSessions 可选,终态会话保留条数,默认 200
|
|
44
|
+
* staleRunningSec 可选,running 会话超过该时长且 Worker 离线则判失败,默认 600;
|
|
45
45
|
* pty 交互会话不受此限,由 ptyIdleSec 空闲回收
|
|
46
46
|
* sigWindowSec 可选,签名时间窗(秒),默认 300;两端时钟漂移大时可调宽
|
|
47
47
|
* ptyIdleSec 可选,pty 会话空闲(Worker 断开且无控制台输入)回收秒数,默认 90
|
|
48
|
-
* imWebhook 可选,钉钉群机器人 webhook
|
|
48
|
+
* imWebhook 可选,钉钉群机器人 webhook 地址;配置后会话终态事件以 markdown 推送到群
|
|
49
49
|
* imSecret 可选,机器人“加签”安全设置的密钥(SEC 开头);关键词/白名单模式留空
|
|
50
50
|
* imNotifyEvents 可选,触发推送的事件集合(数组):done|failed|canceled,默认 [failed]
|
|
51
51
|
*/
|
|
@@ -56,7 +56,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
56
56
|
import { loadServerConfig } from '../lib/config.js';
|
|
57
57
|
import { enableLogFile } from '../lib/logger.js';
|
|
58
58
|
import { json } from '../lib/util.js';
|
|
59
|
-
import {
|
|
59
|
+
import { sessionSummary } from '../lib/summary.js';
|
|
60
60
|
import { createChannels } from '../lib/broadcast.js';
|
|
61
61
|
import { createStore } from '../lib/store.js';
|
|
62
62
|
import { createImNotifier } from '../lib/im.js';
|
|
@@ -82,7 +82,7 @@ const WEB_DIR = path.join(__dirname, '..', 'web');
|
|
|
82
82
|
|
|
83
83
|
const ctx = { cfg };
|
|
84
84
|
Object.assign(ctx, createChannels());
|
|
85
|
-
ctx.
|
|
85
|
+
ctx.sessionSummary = sessionSummary;
|
|
86
86
|
Object.assign(ctx, createStore(ctx));
|
|
87
87
|
ctx.notifyIm = createImNotifier(ctx);
|
|
88
88
|
Object.assign(ctx, createPtyHub(ctx));
|
package/config.example.yaml
CHANGED
|
@@ -16,7 +16,7 @@ workerSecret: ""
|
|
|
16
16
|
|
|
17
17
|
# 可选:多 Worker 独立密钥(workerId -> 密钥映射,每台 Worker 一个,
|
|
18
18
|
# 各用 kites keygen 生成)。配置后服务端把 Worker 身份绑定到验签命中的密钥:
|
|
19
|
-
# 自报 workerId 必须与映射键一致,Worker
|
|
19
|
+
# 自报 workerId 必须与映射键一致,Worker 之间无法互相领取定向会话/伪造上报。
|
|
20
20
|
# 各 Worker 的 worker.yaml 里 workerId 与映射键一致、apiSecret 填对应密钥。
|
|
21
21
|
# 可与上面的 workerSecret 并存(命中共享密钥时身份仍自报,兼容旧部署)
|
|
22
22
|
# workerSecrets:
|
|
@@ -40,10 +40,10 @@ logLevel: info
|
|
|
40
40
|
# 轮转保留代数(server.log.1 … server.log.N),超出自动删除最旧一代
|
|
41
41
|
logKeep: 5
|
|
42
42
|
|
|
43
|
-
# 终态(done/failed/canceled
|
|
44
|
-
|
|
43
|
+
# 终态(done/failed/canceled)会话最多保留条数
|
|
44
|
+
maxSessions: 200
|
|
45
45
|
|
|
46
|
-
# running
|
|
46
|
+
# running 会话超过该秒数且 Worker 离线则判失败(pty 交互会话不受此限)
|
|
47
47
|
staleRunningSec: 600
|
|
48
48
|
|
|
49
49
|
# 签名时间窗(秒);两端时钟漂移大时可调宽
|
|
@@ -52,7 +52,7 @@ sigWindowSec: 300
|
|
|
52
52
|
# pty 会话空闲(Worker 断开且无控制台输入)回收秒数
|
|
53
53
|
ptyIdleSec: 90
|
|
54
54
|
|
|
55
|
-
# 钉钉群机器人 webhook
|
|
55
|
+
# 钉钉群机器人 webhook;配置后会话终态事件以 markdown 推送到群,留空则完全静默
|
|
56
56
|
imWebhook: ""
|
|
57
57
|
|
|
58
58
|
# 机器人启用“加签”安全设置时的密钥(SEC 开头);关键词/白名单模式留空
|
package/lib/api.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* API 路由:Worker
|
|
2
|
+
* API 路由:Worker 领取/心跳/上报,控制台建会话/查询/取消/关终端。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
-
import { json, parseBody,
|
|
6
|
+
import { json, parseBody, publicSession } from './util.js';
|
|
7
7
|
import { workersSnapshot } from './summary.js';
|
|
8
8
|
|
|
9
9
|
const MAX_LOG_LINES = 2000;
|
|
10
|
-
// pending
|
|
11
|
-
// (
|
|
12
|
-
const
|
|
10
|
+
// pending 会话数上限:密钥泄露/控制台失守时防无限灌会话撑爆内存与 sessions.json
|
|
11
|
+
// (pruneFinishedSessions 只清终态,堆积的 pending 没有任何自然出口)
|
|
12
|
+
const MAX_PENDING_SESSIONS = 100;
|
|
13
|
+
// 单个 Worker 同时会话数(pending + running)上限:防止一台 Worker 被堆满终端占死机器资源
|
|
14
|
+
const MAX_SESSIONS_PER_WORKER = 6;
|
|
13
15
|
|
|
14
16
|
export function createApi(ctx) {
|
|
15
17
|
// 身份绑定:配置 workerSecrets 时验签密钥即决定 Worker 身份;
|
|
@@ -21,31 +23,40 @@ export function createApi(ctx) {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
// 归属校验(仅身份绑定生效时启用):只允许领取者上报日志/结果,
|
|
24
|
-
// 防止其他 Worker
|
|
26
|
+
// 防止其他 Worker 伪造他人会话(含未领取会话)的日志与终态;
|
|
25
27
|
// 共享密钥模式(authedWorkerId 为 null)维持旧行为
|
|
26
|
-
function
|
|
27
|
-
return !authedWorkerId ||
|
|
28
|
+
function canReportSession(session, authedWorkerId) {
|
|
29
|
+
return !authedWorkerId || session.workerId === authedWorkerId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 把 pending 会话落为 canceled 并广播(取消接口与关终端接口共用)
|
|
33
|
+
function cancelSession(session) {
|
|
34
|
+
session.status = 'canceled';
|
|
35
|
+
session.finishedAt = Date.now();
|
|
36
|
+
ctx.scheduleSave();
|
|
37
|
+
ctx.broadcast({ type: 'session.updated', session: ctx.sessionSummary(session) });
|
|
38
|
+
ctx.notifyIm(session, 'canceled');
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
async function handleApi(req, res, pathname, rawBody, authedWorkerId) {
|
|
31
42
|
ctx.sweepStaleRunning();
|
|
32
43
|
const body = parseBody(rawBody);
|
|
33
44
|
|
|
34
|
-
// --- Worker:领取最老的 pending
|
|
45
|
+
// --- Worker:领取最老的 pending 会话(原子置为 running)---
|
|
35
46
|
if (req.method === 'POST' && pathname === '/api/worker/claim') {
|
|
36
47
|
const workerId = resolveWorkerId(authedWorkerId, body.workerId);
|
|
37
48
|
if (!workerId) return json(res, 401, { error: 'workerId 与密钥绑定的身份不一致' });
|
|
38
|
-
// 显式指派模型:只领取指名给本 Worker
|
|
39
|
-
const
|
|
49
|
+
// 显式指派模型:只领取指名给本 Worker 的会话,Worker 之间不存在竞争
|
|
50
|
+
const session = ctx.state.sessions.find(
|
|
40
51
|
(t) => t.status === 'pending' && t.targetWorker === workerId,
|
|
41
52
|
);
|
|
42
|
-
if (!
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
if (!session) return json(res, 200, { session: null });
|
|
54
|
+
session.status = 'running';
|
|
55
|
+
session.workerId = workerId;
|
|
56
|
+
session.startedAt = Date.now();
|
|
46
57
|
ctx.scheduleSave();
|
|
47
|
-
ctx.broadcast({ type: '
|
|
48
|
-
return json(res, 200, {
|
|
58
|
+
ctx.broadcast({ type: 'session.updated', session: ctx.sessionSummary(session) });
|
|
59
|
+
return json(res, 200, { session });
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
// --- Worker:心跳 ---
|
|
@@ -71,18 +82,18 @@ export function createApi(ctx) {
|
|
|
71
82
|
return json(res, 200, { workers: workersSnapshot(ctx.state.heartbeats) });
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
// ---
|
|
75
|
-
if (req.method === 'POST' && pathname === '/api/
|
|
85
|
+
// --- 创建会话(pending 超限拒绝,见 MAX_PENDING_SESSIONS)---
|
|
86
|
+
if (req.method === 'POST' && pathname === '/api/sessions') {
|
|
76
87
|
const type = String(body.type || '');
|
|
77
88
|
if (type !== 'pty') {
|
|
78
89
|
return json(res, 400, { error: 'type 必须是 pty' });
|
|
79
90
|
}
|
|
80
|
-
const pendingCount = ctx.state.
|
|
81
|
-
if (pendingCount >=
|
|
82
|
-
return json(res, 429, { error:
|
|
91
|
+
const pendingCount = ctx.state.sessions.filter((t) => t.status === 'pending').length;
|
|
92
|
+
if (pendingCount >= MAX_PENDING_SESSIONS) {
|
|
93
|
+
return json(res, 429, { error: `待执行会话已达上限(${MAX_PENDING_SESSIONS}),请先消化积压` });
|
|
83
94
|
}
|
|
84
95
|
// 显式指派模型:targetWorker 必填且必须是已注册(心跳过)的 Worker,
|
|
85
|
-
//
|
|
96
|
+
// 杜绝无人可领的孤儿会话永远 pending
|
|
86
97
|
const tw = String(body.targetWorker || '').trim().slice(0, 128);
|
|
87
98
|
if (!tw) {
|
|
88
99
|
return json(res, 400, { error: '必须指定目标 Worker(targetWorker)' });
|
|
@@ -94,7 +105,14 @@ export function createApi(ctx) {
|
|
|
94
105
|
return json(res, 400, { error: `Worker "${tw}" 从未注册,无法指派` });
|
|
95
106
|
}
|
|
96
107
|
const targetWorker = tw;
|
|
97
|
-
|
|
108
|
+
// 每 Worker 并发上限:只计进行中会话(终态记录不占额;入口已先经 sweepStaleRunning 回收)
|
|
109
|
+
const activeCount = ctx.state.sessions.filter(
|
|
110
|
+
(t) => t.targetWorker === targetWorker && (t.status === 'pending' || t.status === 'running'),
|
|
111
|
+
).length;
|
|
112
|
+
if (activeCount >= MAX_SESSIONS_PER_WORKER) {
|
|
113
|
+
return json(res, 429, { error: `Worker "${targetWorker}" 同时会话已达上限(${MAX_SESSIONS_PER_WORKER}),请先关闭部分会话` });
|
|
114
|
+
}
|
|
115
|
+
const session = {
|
|
98
116
|
id: crypto.randomUUID(),
|
|
99
117
|
type,
|
|
100
118
|
title: String(body.title || '').slice(0, 200),
|
|
@@ -105,100 +123,122 @@ export function createApi(ctx) {
|
|
|
105
123
|
createdAt: Date.now(),
|
|
106
124
|
startedAt: null,
|
|
107
125
|
finishedAt: null,
|
|
126
|
+
openedAt: Date.now(), // 新建即视为已打开(控制台开窗标记,服务端持久化供任意客户端恢复)
|
|
108
127
|
workerId: null,
|
|
109
128
|
targetWorker,
|
|
110
129
|
};
|
|
111
|
-
ctx.state.
|
|
130
|
+
ctx.state.sessions.push(session);
|
|
112
131
|
ctx.scheduleSave();
|
|
113
|
-
ctx.broadcast({ type: '
|
|
114
|
-
ctx.notifyWorkers(targetWorker); //
|
|
115
|
-
return json(res, 201,
|
|
132
|
+
ctx.broadcast({ type: 'session.created', session: ctx.sessionSummary(session) });
|
|
133
|
+
ctx.notifyWorkers(targetWorker); // 会话到达即推送,Worker 立即领取,不再等轮询周期
|
|
134
|
+
return json(res, 201, publicSession(session));
|
|
116
135
|
}
|
|
117
136
|
|
|
118
|
-
// ---
|
|
119
|
-
if (req.method === 'GET' && pathname === '/api/
|
|
137
|
+
// --- 会话列表 ---
|
|
138
|
+
if (req.method === 'GET' && pathname === '/api/sessions') {
|
|
120
139
|
const url = new URL(req.url, 'http://localhost');
|
|
121
140
|
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 50, 200));
|
|
122
|
-
const list = [...ctx.state.
|
|
141
|
+
const list = [...ctx.state.sessions]
|
|
123
142
|
.sort((a, b) => b.createdAt - a.createdAt)
|
|
124
143
|
.slice(0, limit)
|
|
125
|
-
.map(ctx.
|
|
126
|
-
return json(res, 200, {
|
|
144
|
+
.map(ctx.sessionSummary);
|
|
145
|
+
return json(res, 200, { sessions: list });
|
|
127
146
|
}
|
|
128
147
|
|
|
129
|
-
// ---
|
|
130
|
-
const
|
|
131
|
-
if (req.method === 'GET' &&
|
|
132
|
-
const
|
|
133
|
-
if (!
|
|
134
|
-
return json(res, 200,
|
|
148
|
+
// --- 单个会话详情(含日志)---
|
|
149
|
+
const sessionMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]{36})$/);
|
|
150
|
+
if (req.method === 'GET' && sessionMatch) {
|
|
151
|
+
const session = ctx.state.sessions.find((t) => t.id === sessionMatch[1]);
|
|
152
|
+
if (!session) return json(res, 404, { error: 'session not found' });
|
|
153
|
+
return json(res, 200, publicSession(session));
|
|
135
154
|
}
|
|
136
155
|
|
|
137
156
|
// --- 追加日志(Worker)---
|
|
138
|
-
const logMatch = pathname.match(/^\/api\/
|
|
157
|
+
const logMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]{36})\/logs$/);
|
|
139
158
|
if (req.method === 'POST' && logMatch) {
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
142
|
-
if (!
|
|
143
|
-
return json(res, 403, { error: '
|
|
159
|
+
const session = ctx.state.sessions.find((t) => t.id === logMatch[1]);
|
|
160
|
+
if (!session) return json(res, 404, { error: 'session not found' });
|
|
161
|
+
if (!canReportSession(session, authedWorkerId)) {
|
|
162
|
+
return json(res, 403, { error: '只能上报自己领取的会话' });
|
|
144
163
|
}
|
|
145
164
|
const lines = Array.isArray(body.lines) ? body.lines : [];
|
|
146
165
|
for (const line of lines) {
|
|
147
|
-
|
|
166
|
+
session.logs.push(String(line).slice(0, 4000));
|
|
148
167
|
}
|
|
149
|
-
if (
|
|
150
|
-
|
|
168
|
+
if (session.logs.length > MAX_LOG_LINES) {
|
|
169
|
+
session.logs.splice(0, session.logs.length - MAX_LOG_LINES);
|
|
151
170
|
}
|
|
152
171
|
ctx.scheduleSave();
|
|
153
|
-
ctx.broadcast({ type: '
|
|
154
|
-
return json(res, 200, { ok: true, logCount:
|
|
172
|
+
ctx.broadcast({ type: 'session.logs', id: session.id, lines });
|
|
173
|
+
return json(res, 200, { ok: true, logCount: session.logs.length });
|
|
155
174
|
}
|
|
156
175
|
|
|
157
|
-
// ---
|
|
158
|
-
const finishMatch = pathname.match(/^\/api\/
|
|
176
|
+
// --- 结束会话(Worker)---
|
|
177
|
+
const finishMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]{36})\/finish$/);
|
|
159
178
|
if (req.method === 'POST' && finishMatch) {
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
162
|
-
if (!
|
|
163
|
-
return json(res, 403, { error: '
|
|
179
|
+
const session = ctx.state.sessions.find((t) => t.id === finishMatch[1]);
|
|
180
|
+
if (!session) return json(res, 404, { error: 'session not found' });
|
|
181
|
+
if (!canReportSession(session, authedWorkerId)) {
|
|
182
|
+
return json(res, 403, { error: '只能上报自己领取的会话' });
|
|
164
183
|
}
|
|
165
|
-
// 状态守卫:已到终态(被 sweepStaleRunning
|
|
184
|
+
// 状态守卫:已到终态(被 sweepStaleRunning 判失败回收、控制台取消等)的会话
|
|
166
185
|
// 拒绝迟到的 finish,防止短暂失联后"复活"的 Worker 把终态翻回
|
|
167
|
-
if (
|
|
168
|
-
return json(res, 409, { error:
|
|
186
|
+
if (session.status !== 'running') {
|
|
187
|
+
return json(res, 409, { error: `会话已处于终态(${session.status}),拒绝 finish 上报` });
|
|
169
188
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
session.status = body.status === 'done' ? 'done' : 'failed';
|
|
190
|
+
session.result = body.result ?? null;
|
|
191
|
+
session.finishedAt = Date.now();
|
|
173
192
|
ctx.scheduleSave();
|
|
174
|
-
ctx.broadcast({ type: '
|
|
175
|
-
ctx.notifyIm(
|
|
176
|
-
return json(res, 200,
|
|
193
|
+
ctx.broadcast({ type: 'session.updated', session: ctx.sessionSummary(session), result: session.result });
|
|
194
|
+
ctx.notifyIm(session, session.status);
|
|
195
|
+
return json(res, 200, publicSession(session));
|
|
177
196
|
}
|
|
178
197
|
|
|
179
|
-
// ---
|
|
180
|
-
const cancelMatch = pathname.match(/^\/api\/
|
|
198
|
+
// --- 取消会话(控制台;仅对 pending 生效)---
|
|
199
|
+
const cancelMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]{36})\/cancel$/);
|
|
181
200
|
if (req.method === 'POST' && cancelMatch) {
|
|
182
|
-
const
|
|
183
|
-
if (!
|
|
184
|
-
if (
|
|
185
|
-
return json(res, 409, { error: `只能取消 pending
|
|
201
|
+
const session = ctx.state.sessions.find((t) => t.id === cancelMatch[1]);
|
|
202
|
+
if (!session) return json(res, 404, { error: 'session not found' });
|
|
203
|
+
if (session.status !== 'pending') {
|
|
204
|
+
return json(res, 409, { error: `只能取消 pending 会话,当前状态:${session.status}` });
|
|
186
205
|
}
|
|
187
|
-
|
|
188
|
-
|
|
206
|
+
cancelSession(session);
|
|
207
|
+
return json(res, 200, publicSession(session));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- 标记会话终端已打开(控制台):开窗即落服务端,刷新/换浏览器都能恢复现场;
|
|
211
|
+
// 关窗即关会话(kill 删记录),故不需要「取消打开」接口 ---
|
|
212
|
+
const openMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]{36})\/open$/);
|
|
213
|
+
if (req.method === 'POST' && openMatch) {
|
|
214
|
+
const session = ctx.state.sessions.find((t) => t.id === openMatch[1]);
|
|
215
|
+
if (!session) return json(res, 404, { error: 'session not found' });
|
|
216
|
+
session.openedAt = Date.now();
|
|
189
217
|
ctx.scheduleSave();
|
|
190
|
-
ctx.broadcast({ type: '
|
|
191
|
-
|
|
192
|
-
return json(res, 200, publicTask(task));
|
|
218
|
+
ctx.broadcast({ type: 'session.updated', session: ctx.sessionSummary(session) });
|
|
219
|
+
return json(res, 200, { ok: true });
|
|
193
220
|
}
|
|
194
221
|
|
|
195
|
-
// ---
|
|
196
|
-
//
|
|
197
|
-
|
|
222
|
+
// --- 关闭交互终端(控制台):✕ 关窗即关会话——关闭即删除记录,刷新不再回来 ---
|
|
223
|
+
// 有活跃通道先通知 Worker 杀 PTY,随后从会话列表删除并广播 session.closed;
|
|
224
|
+
// 迟到的 exited/finish 上报只会收到 404(Worker 侧已容忍);
|
|
225
|
+
// 记录已删但 Worker 仍残留的 PTY,在重连重挂时由无记录守卫回收(见 websocket.js)
|
|
226
|
+
const killMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]{36})\/kill$/);
|
|
198
227
|
if (req.method === 'POST' && killMatch) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
228
|
+
const id = killMatch[1];
|
|
229
|
+
const session = ctx.state.sessions.find((t) => t.id === id);
|
|
230
|
+
if (!session) return json(res, 404, { error: 'session not found' });
|
|
231
|
+
const s = ctx.ptySessions.get(id);
|
|
232
|
+
if (s && s.ws && s.ws.readyState === s.ws.OPEN) {
|
|
233
|
+
ctx.wsSendJson(s.ws, { type: 'session.close', sessionId: id });
|
|
234
|
+
}
|
|
235
|
+
if (!['done', 'failed', 'canceled'].includes(session.status)) {
|
|
236
|
+
ctx.notifyIm(session, 'canceled'); // 主动关闭视作取消类事件,是否推送仍由配置决定
|
|
237
|
+
}
|
|
238
|
+
ctx.state.sessions.splice(ctx.state.sessions.indexOf(session), 1);
|
|
239
|
+
ctx.ptySessions.delete(id);
|
|
240
|
+
ctx.scheduleSave();
|
|
241
|
+
ctx.broadcast({ type: 'session.closed', id });
|
|
202
242
|
return json(res, 200, { ok: true });
|
|
203
243
|
}
|
|
204
244
|
|
package/lib/auth.js
CHANGED
|
@@ -98,7 +98,7 @@ export function createAuth(ctx) {
|
|
|
98
98
|
|
|
99
99
|
// 路由 -> 可接受的密钥条目:Worker 侧 API 只认 WORKER_AUTH 条目,控制台只认
|
|
100
100
|
// API_SECRET;worker/status 两端都要读(控制台轮询兜底也用),两边密钥都放行
|
|
101
|
-
const WORKER_PATH = /^\/api\/(worker\/(claim|heartbeat)|
|
|
101
|
+
const WORKER_PATH = /^\/api\/(worker\/(claim|heartbeat)|sessions\/[0-9a-f-]{36}\/(logs|finish))$/;
|
|
102
102
|
function secretsForPath(pathname) {
|
|
103
103
|
if (pathname === '/api/worker/status') {
|
|
104
104
|
return [{ secret: API_SECRET, workerId: null }, ...WORKER_AUTH];
|
package/lib/broadcast.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebSocket 实时推送:控制台通过 /ws
|
|
3
|
-
* Worker 通过 /worker/ws
|
|
2
|
+
* WebSocket 实时推送:控制台通过 /ws 建立长连接,会话变更即时下发;
|
|
3
|
+
* Worker 通过 /worker/ws 建立长连接,仅接收会话到达通知。
|
|
4
4
|
* 本模块只维护已鉴权连接集合与广播函数,连接建立见 lib/websocket.js。
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -21,7 +21,7 @@ export function createChannels() {
|
|
|
21
21
|
// 显式指派模型:只唤醒指名 Worker 的连接,其他 Worker 完全不被打扰
|
|
22
22
|
function notifyWorkers(targetWorker) {
|
|
23
23
|
if (!wsWorkers.size || !targetWorker) return;
|
|
24
|
-
const data = JSON.stringify({ type: '
|
|
24
|
+
const data = JSON.stringify({ type: 'session.available', targetWorker });
|
|
25
25
|
for (const ws of wsWorkers) {
|
|
26
26
|
if (ws.workerId === targetWorker) ws.send(data);
|
|
27
27
|
}
|
package/lib/config.js
CHANGED
|
@@ -90,7 +90,7 @@ const CONFIG_SCHEMA = z.strictObject({
|
|
|
90
90
|
.min(1, 'logKeep 至少保留 1 代')
|
|
91
91
|
.max(100, 'logKeep 超出范围 [1, 100]')
|
|
92
92
|
.default(5),
|
|
93
|
-
|
|
93
|
+
maxSessions: z.coerce.number({ error: 'maxSessions 必须是数字' }).positive('maxSessions 必须是正数').default(200),
|
|
94
94
|
staleRunningSec: z.coerce
|
|
95
95
|
.number({ error: 'staleRunningSec 必须是数字' })
|
|
96
96
|
.positive('staleRunningSec 必须是正数')
|
|
@@ -269,7 +269,7 @@ export async function loadServerConfig() {
|
|
|
269
269
|
logFile,
|
|
270
270
|
logLevel: v.logLevel,
|
|
271
271
|
logKeep: v.logKeep,
|
|
272
|
-
|
|
272
|
+
maxSessions: v.maxSessions,
|
|
273
273
|
staleRunningMs: v.staleRunningSec * 1000,
|
|
274
274
|
sigWindowSec: v.sigWindowSec,
|
|
275
275
|
ptyIdleMs: v.ptyIdleSec * 1000,
|
package/lib/im.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* IM
|
|
2
|
+
* IM 群通知:会话终态事件推送钉钉群机器人(markdown),走 dingbot SDK;
|
|
3
3
|
* 机器人启用“加签”安全设置时由 imSecret 自动完成 timestamp+sign 拼接。
|
|
4
4
|
* 未配置 imWebhook 完全静默;发送失败只记日志,绝不抛出影响主链路。
|
|
5
5
|
*/
|
|
@@ -17,16 +17,16 @@ export function createImNotifier(ctx) {
|
|
|
17
17
|
|
|
18
18
|
const IM_BOT = IM_WEBHOOK ? new DingBot(IM_WEBHOOK, { timeout: 10_000 }, IM_SECRET || undefined) : null;
|
|
19
19
|
|
|
20
|
-
// 推送摘要:pty
|
|
21
|
-
function imPayloadBrief(
|
|
22
|
-
const p =
|
|
23
|
-
if (
|
|
20
|
+
// 推送摘要:pty 会话只有项目目录
|
|
21
|
+
function imPayloadBrief(session) {
|
|
22
|
+
const p = session.payload || {};
|
|
23
|
+
if (session.type === 'pty') {
|
|
24
24
|
return p.project ? `交互终端(项目:${p.project})` : '交互终端';
|
|
25
25
|
}
|
|
26
26
|
return '';
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function notifyIm(
|
|
29
|
+
function notifyIm(session, event) {
|
|
30
30
|
if (!IM_BOT || !IM_NOTIFY_EVENTS.has(event)) return;
|
|
31
31
|
const meta = IM_STATUS_META[event] || { icon: 'ℹ️', label: event };
|
|
32
32
|
const fmtTime = (ms) => new Date(ms).toLocaleString('zh-CN', { hour12: false });
|
|
@@ -35,35 +35,35 @@ export function createImNotifier(ctx) {
|
|
|
35
35
|
const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 8)} … ${s.slice(-4)}` : s);
|
|
36
36
|
|
|
37
37
|
const lines = [
|
|
38
|
-
`### ${meta.icon}
|
|
39
|
-
`- 类型:${
|
|
38
|
+
`### ${meta.icon} 会话${meta.label}:${session.title || session.type}`,
|
|
39
|
+
`- 类型:${session.type}`,
|
|
40
40
|
];
|
|
41
|
-
const hb =
|
|
42
|
-
if (
|
|
43
|
-
lines.push(`- Worker:${hb?.hostname ? `${hb.hostname}(${
|
|
41
|
+
const hb = session.workerId ? ctx.state.heartbeats[session.workerId] : null;
|
|
42
|
+
if (session.workerId) {
|
|
43
|
+
lines.push(`- Worker:${hb?.hostname ? `${hb.hostname}(${session.workerId})` : session.workerId}`);
|
|
44
44
|
}
|
|
45
|
-
const brief = imPayloadBrief(
|
|
45
|
+
const brief = imPayloadBrief(session);
|
|
46
46
|
if (brief) lines.push(`- 内容:${truncate(brief, 200)}`);
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
if (
|
|
50
|
-
if (
|
|
51
|
-
lines.push(`- 耗时:${fmtDur(
|
|
47
|
+
if (session.createdAt) lines.push(`- 创建:${fmtTime(session.createdAt)}`);
|
|
48
|
+
if (session.startedAt) lines.push(`- 开始:${fmtTime(session.startedAt)}`);
|
|
49
|
+
if (session.finishedAt) lines.push(`- 结束:${fmtTime(session.finishedAt)}`);
|
|
50
|
+
if (session.startedAt && session.finishedAt) {
|
|
51
|
+
lines.push(`- 耗时:${fmtDur(session.finishedAt - session.startedAt)}`);
|
|
52
52
|
}
|
|
53
|
-
if (
|
|
54
|
-
const detail = String(
|
|
53
|
+
if (session.result) {
|
|
54
|
+
const detail = String(session.result.error || JSON.stringify(session.result)).slice(0, 300);
|
|
55
55
|
if (detail !== '{}') lines.push(`- 结果:${detail}`);
|
|
56
56
|
}
|
|
57
57
|
// 日志末尾几行,失败时可直接看到报错上下文
|
|
58
|
-
const tail = (
|
|
58
|
+
const tail = (session.logs || [])
|
|
59
59
|
.filter((l) => !/^\[(server|worker)\]/.test(l) && l.trim())
|
|
60
60
|
.slice(-3)
|
|
61
61
|
.map((l) => truncate(String(l), 150));
|
|
62
62
|
if (tail.length) lines.push('**日志末尾:**', ...tail.map((l) => `> ${l}`));
|
|
63
|
-
lines.push(
|
|
63
|
+
lines.push(`会话 ID:\`${session.id}\``);
|
|
64
64
|
// SDK 内部校验钉钉返回的 errcode(关键词/加签拦截、限流等业务错误)并抛错
|
|
65
|
-
IM_BOT.markdown(`kite
|
|
66
|
-
.catch((err) => console.error(`[server] IM 推送失败(${
|
|
65
|
+
IM_BOT.markdown(`kite 会话${meta.label}`, lines.join('\n'))
|
|
66
|
+
.catch((err) => console.error(`[server] IM 推送失败(${session.id}):${err.message}`));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
return notifyIm;
|
package/lib/logger.js
CHANGED
|
@@ -84,7 +84,7 @@ export function createFileLogger(logFile, { level = 'info', keep = KEEP_FILES, m
|
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
// 异步串行队列:打开/写入/轮转/关闭按调用顺序逐个执行;
|
|
87
|
-
// 队列内部消化所有异常,永不 reject
|
|
87
|
+
// 队列内部消化所有异常,永不 reject(否则后续会话会被跳过)
|
|
88
88
|
let queue = open();
|
|
89
89
|
|
|
90
90
|
const append = (lvl, args) => {
|
package/lib/pty.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pty 会话中继:
|
|
2
|
+
* pty 会话中继:sessionId -> { ws, tail, lastActiveAt }
|
|
3
3
|
* Worker 上行 session.output -> 追加回看缓冲并广播控制台;
|
|
4
4
|
* 控制台下行 session.input/resize/close -> 定向转发给持有会话的 Worker。
|
|
5
5
|
* 回看缓冲只留末尾 16KB,手机切后台重连终端不至于白屏。
|
|
@@ -16,33 +16,33 @@ export function createPtyHub(ctx) {
|
|
|
16
16
|
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// 关闭会话并(可选)把 running 的 pty
|
|
20
|
-
//
|
|
21
|
-
function closePtySession(
|
|
22
|
-
const s = ptySessions.get(
|
|
23
|
-
if (s) ptySessions.delete(
|
|
19
|
+
// 关闭会话并(可选)把 running 的 pty 会话落为终态;reason 为空表示 Worker
|
|
20
|
+
// 正常上报退出,会话终态由 Worker 的 finish 上报负责,这里只清会话
|
|
21
|
+
function closePtySession(sessionId, { status, error, reason }) {
|
|
22
|
+
const s = ptySessions.get(sessionId);
|
|
23
|
+
if (s) ptySessions.delete(sessionId);
|
|
24
24
|
if (status) {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (reason)
|
|
25
|
+
const session = ctx.state.sessions.find((t) => t.id === sessionId);
|
|
26
|
+
if (session && session.status === 'running') {
|
|
27
|
+
session.status = status;
|
|
28
|
+
session.finishedAt = Date.now();
|
|
29
|
+
session.result = { error };
|
|
30
|
+
if (reason) session.logs.push(reason);
|
|
31
31
|
ctx.scheduleSave();
|
|
32
|
-
ctx.broadcast({ type: '
|
|
32
|
+
ctx.broadcast({ type: 'session.updated', session: ctx.sessionSummary(session), result: session.result });
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
ctx.broadcast({ type: 'pty.exited', id:
|
|
35
|
+
ctx.broadcast({ type: 'pty.exited', id: sessionId, reason: error || 'closed' });
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Worker 通道断开或会话长时间无活动(Worker 掉线且无控制台输入)时回收,
|
|
39
39
|
// 防止忘关的终端在 Worker 上常驻
|
|
40
40
|
function sweepPtyIdle() {
|
|
41
41
|
const now = Date.now();
|
|
42
|
-
for (const [
|
|
42
|
+
for (const [sessionId, s] of ptySessions) {
|
|
43
43
|
const workerGone = !s.ws || s.ws.readyState !== s.ws.OPEN;
|
|
44
44
|
if (workerGone && now - s.lastActiveAt > PTY_IDLE_MS) {
|
|
45
|
-
closePtySession(
|
|
45
|
+
closePtySession(sessionId, {
|
|
46
46
|
status: 'failed',
|
|
47
47
|
error: '终端会话空闲超时,已被服务端回收',
|
|
48
48
|
reason: '[server] 终端会话空闲超时(Worker 断开),已回收',
|