@jacksontian/kite-server 0.3.0 → 0.4.1
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 +44 -8
- package/bin/server.js +191 -0
- package/config.example.yaml +13 -3
- package/deploy/kiteserver.service +2 -2
- package/lib/api.js +209 -0
- package/lib/auth.js +130 -0
- package/lib/broadcast.js +31 -0
- package/lib/cli/args.js +83 -0
- package/lib/cli/deploy.js +94 -0
- package/lib/cli/init.js +208 -0
- package/lib/cli/keygen.js +23 -0
- package/lib/cli/meta.js +56 -0
- package/lib/cli/util.js +44 -0
- package/lib/config.js +280 -0
- package/lib/im.js +70 -0
- package/lib/logger.js +129 -0
- package/lib/pty.js +55 -0
- package/lib/static.js +51 -0
- package/lib/store.js +115 -0
- package/lib/summary.js +30 -0
- package/lib/util.js +37 -0
- package/lib/websocket.js +261 -0
- package/package.json +22 -6
- package/web/index.html +312 -222
- package/web/pc.html +533 -219
- package/config.js +0 -540
- package/server.js +0 -1103
package/README.md
CHANGED
|
@@ -19,17 +19,19 @@
|
|
|
19
19
|
npm i -g @jacksontian/kite-server
|
|
20
20
|
|
|
21
21
|
# 一步生成配置文件(自动填好随机 apiSecret/workerSecret,打印在输出里请保存)
|
|
22
|
-
kites init #
|
|
23
|
-
#
|
|
22
|
+
kites init # 不带路径默认生成到 ~/.kites/conf/server.yaml(与 start/check 缺省读取的路径一致);
|
|
23
|
+
# 生产部署落标准树:sudo -Hu www-data kites init(www-data 家目录即 /var/www/.kites)
|
|
24
|
+
# 手工方式:cp "$(npm root -g)/@jacksontian/kite-server/config.example.yaml" ~/.kites/conf/server.yaml 后编辑,
|
|
24
25
|
# 密钥用 kites keygen 或下面命令生成(永不在网络上传输):
|
|
25
26
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
26
27
|
|
|
27
|
-
kites check
|
|
28
|
-
kites start
|
|
28
|
+
kites check # 干跑校验:只检查配置不启动服务,通过后退出
|
|
29
|
+
kites start # 前台启动服务;常驻托管交给 systemd(见下文 deploy)
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
启动后浏览器打开 `http://<vps>:8787`,粘贴密钥即可进入控制台。
|
|
32
|
-
配置统一走 YAML
|
|
33
|
+
配置统一走 YAML 文件,不读取环境变量/.env;`--config` 可显式指定路径,
|
|
34
|
+
不传时默认读 `~/.kites/conf/server.yaml`(init 缺省也生成到这里)。
|
|
33
35
|
`kites` 是完全命令行化的工具:启动服务必须显式 `kites start`,裸 `kites`
|
|
34
36
|
(不带子命令)只打印用法、不会启动服务。`kites --help` 查看完整用法,
|
|
35
37
|
`kites --version` 查看版本号。
|
|
@@ -42,18 +44,52 @@ kites start --config /opt/kite/server.yaml # 前台启动服务;常驻托管
|
|
|
42
44
|
|---|---|
|
|
43
45
|
| `apiSecret` | 必填,两端共享的 HMAC 签名密钥 |
|
|
44
46
|
| `port` | 监听端口,默认 8787 |
|
|
45
|
-
| `dataDir` | 持久化目录,建议绝对路径,支持 `~` 展开,默认 `~/.
|
|
47
|
+
| `dataDir` | 持久化目录,建议绝对路径,支持 `~` 展开,默认 `~/.kites/data` |
|
|
48
|
+
| `logFile` | 程序自写日志文件,默认 `~/.kites/logs/server.log`;超 10MB 轮转并逐代顺延(`.1` 最新),stdout 输出不受影响 |
|
|
49
|
+
| `logLevel` | 日志落盘最低级别(`debug`\|`info`\|`warn`\|`error`),默认 `info`;只过滤落盘,前台输出不受影响 |
|
|
50
|
+
| `logKeep` | 轮转保留代数(`server.log.1` … `.N`),默认 5,超出自动删除最旧一代 |
|
|
46
51
|
| `maxTasks` | 终态任务最多保留条数 |
|
|
47
52
|
| `staleRunningSec` | running 任务超时回收秒数(Worker 离线时判失败,pty 会话不受此限) |
|
|
48
53
|
| `sigWindowSec` | 签名时间窗(秒),两端时钟漂移大时可调宽 |
|
|
49
54
|
| `ptyIdleSec` | pty 会话空闲回收秒数 |
|
|
50
55
|
| `imWebhook` / `imSecret` / `imNotifyEvents` | 钉钉群机器人通知,留空则完全静默 |
|
|
51
56
|
|
|
57
|
+
## 标准工作目录结构
|
|
58
|
+
|
|
59
|
+
kites 的所有可变状态收拢在家目录下的 `~/.kites/` 标准树(类比 nginx/mysql 的
|
|
60
|
+
conf/data/logs 布局);生产托管以 `www-data` 运行时即 `/var/www/.kites/`:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
~/.kites/
|
|
64
|
+
├── conf/
|
|
65
|
+
│ └── server.yaml # 配置(默认读这里,可用 --config 指定别处;模板见包内 config.example.yaml)
|
|
66
|
+
├── data/ # dataDir:持久化目录
|
|
67
|
+
│ ├── tasks.json # 任务队列 + 日志 + 结果(0600,含全部命令与输出,勿外传)
|
|
68
|
+
│ ├── tasks.json.tmp # 落盘中转文件(写完整后 rename 原子替换,正常情况不会残留)
|
|
69
|
+
│ └── tasks.json.corrupt-<ts> # tasks.json 解析失败时的自动备份(仅在损坏时出现)
|
|
70
|
+
└── logs/
|
|
71
|
+
├── server.log # logFile:程序自写日志(每条一行:时间/级别/pid/内容)
|
|
72
|
+
└── server.log.1 … .N # 轮转历史(超 10MB 顺延一代,保留 logKeep 代,默认 5)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
约定:
|
|
76
|
+
|
|
77
|
+
- 各路径支持 `~` 展开;相对路径相对**启动时的工作目录**解析(systemd 托管下即
|
|
78
|
+
unit 里的 `WorkingDirectory`),建议直接写绝对路径
|
|
79
|
+
- 目录不存在时启动自动创建,并先做可写探测,不可写直接报错退出
|
|
80
|
+
- 已显式配置路径的存量部署不受标准树影响(只改默认值,不迁移任何数据)
|
|
81
|
+
- 备份/迁移时整个 `~/.kites/` 拷走即可;systemd 部署需给运行用户(www-data)
|
|
82
|
+
整棵树的读写权限
|
|
83
|
+
- Web 控制台静态文件(`web/`)随 npm 包分发,不属于工作目录,不需要也不应该手工维护
|
|
84
|
+
- 日志轮转只做「超 10MB 保留一代」,需要更长的保留策略请配系统工具(logrotate)
|
|
85
|
+
|
|
52
86
|
## 生产部署
|
|
53
87
|
|
|
88
|
+
- 生产形态即上图标准树落 `www-data` 家目录:`sudo -Hu www-data kites init`
|
|
89
|
+
(缺省即生成到 `/var/www/.kites/conf/server.yaml`,conf/data/logs 同根,权限给 www-data 即可)
|
|
54
90
|
- systemd unit:`kites deploy` 一步生成占位符已替换好的 unit(包内模板
|
|
55
|
-
`deploy/kiteserver.service
|
|
56
|
-
|
|
91
|
+
`deploy/kiteserver.service`,缺省指向 `/var/www/.kites/conf/server.yaml`),
|
|
92
|
+
再 `sudo install` 到 /etc/systemd/system/
|
|
57
93
|
- 建议 Nginx/Caddy 反代加 HTTPS(Caddy 有域名即自动签证书);仅 HTTP 部署时
|
|
58
94
|
按根 README「仅 HTTP 部署」一节做 IP 白名单与密钥轮换
|
|
59
95
|
- `/health` 免签名探活端点,GET 返回 `{ ok: true }`,供 Caddy upstream health /
|
package/bin/server.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kite 云端信箱 API
|
|
4
|
+
*
|
|
5
|
+
* 职责:签名鉴权、任务信箱(手机写入 / Worker 领取)、日志与结果收集、
|
|
6
|
+
* Worker 心跳、托管手机端 Web 控制台。
|
|
7
|
+
*
|
|
8
|
+
* 模块划分(lib/):持久化 store、签名鉴权 auth、API 路由 api、IM 通知 im、
|
|
9
|
+
* WS 推送 broadcast/websocket、pty 中继 pty、摘要视图 summary、静态文件 static、
|
|
10
|
+
* 工具 util;本文件只负责装配 ctx 与 HTTP/WS 入口。
|
|
11
|
+
*
|
|
12
|
+
* 依赖:ws(WebSocket)、dingbot(钉钉群机器人)、yaml、zod(配置校验,见 config.js)。
|
|
13
|
+
* 其余为 Node 内置模块。Node >= 24。
|
|
14
|
+
*
|
|
15
|
+
* 鉴权模型:HMAC-SHA256 请求签名。密钥(API_SECRET)只存在两端,
|
|
16
|
+
* 永不在网络上传输;HTTP 明文下抓到请求也无法重放(时间窗 + nonce)。
|
|
17
|
+
* 签名串:方法、路径、时间戳、nonce、请求体 SHA256,五行以换行符连接
|
|
18
|
+
* 请求头:X-RW-Ts(秒级时间戳)、X-RW-Nonce(随机串)、X-RW-Sig(hex)
|
|
19
|
+
* WS 鉴权:首条消息 { type:'auth', ts, nonce, sig },签名串 "WS\nTS\nNONCE"
|
|
20
|
+
* WS 通道:/ws 控制台实时广播 + pty 终端输入上行;/worker/ws Worker 任务通知
|
|
21
|
+
* 与 pty 会话 IO 中继(session.output 下行广播、session.input 定向转发)。
|
|
22
|
+
* 有任务即推 task.available,领取仍走 HTTP claim,轮询兜底不丢任务
|
|
23
|
+
*
|
|
24
|
+
* 配置文件(--config 传入的 YAML 文件,唯一配置来源,不再读取环境变量;
|
|
25
|
+
* 字段说明与模板见包内 config.example.yaml):
|
|
26
|
+
* apiSecret 必填,控制台签名密钥(强随机 hex,见 README 快速开始)
|
|
27
|
+
* workerSecret 可选,Worker 专用密钥;配置后 Worker 侧 API 与 /worker/ws
|
|
28
|
+
* 只认该密钥,控制台密钥泄露不再等于 Worker 执行权;
|
|
29
|
+
* 留空则两端共用 apiSecret
|
|
30
|
+
* workerSecrets 可选,workerId -> 密钥映射(多 Worker 各自专属密钥);
|
|
31
|
+
* 验签命中映射条目即绑定该 Worker 身份(自报 workerId 须一致),
|
|
32
|
+
* Worker 间无法互相冒充;与 workerSecret 可并存(命中共享
|
|
33
|
+
* 密钥时身份自报,兼容旧部署)
|
|
34
|
+
* port 可选,默认 8787
|
|
35
|
+
* dataDir 可选,持久化目录,默认 ~/.kites/data;
|
|
36
|
+
* 相对路径相对启动工作目录解析,建议写绝对路径;
|
|
37
|
+
* 启动前校验可创建、可写,不满足直接报错退出
|
|
38
|
+
* logFile 可选,程序自写日志文件,默认 ~/.kites/logs/server.log;
|
|
39
|
+
* 超 10MB 轮转并逐代顺延(.1 最新);stdout 输出不受影响
|
|
40
|
+
* logLevel 可选,日志落盘最低级别(debug|info|warn|error),默认 info;
|
|
41
|
+
* 只过滤落盘,前台输出不受影响
|
|
42
|
+
* logKeep 可选,轮转保留代数(server.log.1 … .N),默认 5
|
|
43
|
+
* maxTasks 可选,终态任务保留条数,默认 200
|
|
44
|
+
* staleRunningSec 可选,running 任务超过该时长且 Worker 离线则判失败,默认 600;
|
|
45
|
+
* pty 交互会话不受此限,由 ptyIdleSec 空闲回收
|
|
46
|
+
* sigWindowSec 可选,签名时间窗(秒),默认 300;两端时钟漂移大时可调宽
|
|
47
|
+
* ptyIdleSec 可选,pty 会话空闲(Worker 断开且无控制台输入)回收秒数,默认 90
|
|
48
|
+
* imWebhook 可选,钉钉群机器人 webhook 地址;配置后任务终态事件以 markdown 推送到群
|
|
49
|
+
* imSecret 可选,机器人“加签”安全设置的密钥(SEC 开头);关键词/白名单模式留空
|
|
50
|
+
* imNotifyEvents 可选,触发推送的事件集合(数组):done|failed|canceled,默认 [failed]
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import http from 'node:http';
|
|
54
|
+
import path from 'node:path';
|
|
55
|
+
import { fileURLToPath } from 'node:url';
|
|
56
|
+
import { loadServerConfig } from '../lib/config.js';
|
|
57
|
+
import { enableLogFile } from '../lib/logger.js';
|
|
58
|
+
import { json } from '../lib/util.js';
|
|
59
|
+
import { taskSummary } from '../lib/summary.js';
|
|
60
|
+
import { createChannels } from '../lib/broadcast.js';
|
|
61
|
+
import { createStore } from '../lib/store.js';
|
|
62
|
+
import { createImNotifier } from '../lib/im.js';
|
|
63
|
+
import { createPtyHub } from '../lib/pty.js';
|
|
64
|
+
import { createAuth } from '../lib/auth.js';
|
|
65
|
+
import { createApi } from '../lib/api.js';
|
|
66
|
+
import { createStaticServer } from '../lib/static.js';
|
|
67
|
+
import { createWebSockets } from '../lib/websocket.js';
|
|
68
|
+
|
|
69
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
|
|
71
|
+
const cfg = await loadServerConfig();
|
|
72
|
+
// 日志落盘(logs/ 标准目录):只包装 console 追加写文件,stdout/journal 捕获不受影响;
|
|
73
|
+
// 落盘级别与轮转保留代数随配置走(行格式与轮转策略见 lib/logger.js)
|
|
74
|
+
enableLogFile(cfg.logFile, { level: cfg.logLevel, keep: cfg.logKeep });
|
|
75
|
+
const WEB_DIR = path.join(__dirname, '..', 'web');
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// ctx 装配:各 lib 模块共享的运行时上下文。装配顺序即依赖顺序;
|
|
79
|
+
// 跨模块调用(如 sweepStaleRunning -> broadcast/notifyIm)都发生在运行期,
|
|
80
|
+
// 晚于装配完成,故经 ctx 间接引用无时序问题。
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const ctx = { cfg };
|
|
84
|
+
Object.assign(ctx, createChannels());
|
|
85
|
+
ctx.taskSummary = taskSummary;
|
|
86
|
+
Object.assign(ctx, createStore(ctx));
|
|
87
|
+
ctx.notifyIm = createImNotifier(ctx);
|
|
88
|
+
Object.assign(ctx, createPtyHub(ctx));
|
|
89
|
+
ctx.auth = createAuth(ctx);
|
|
90
|
+
ctx.handleApi = createApi(ctx);
|
|
91
|
+
const serveStatic = createStaticServer(WEB_DIR);
|
|
92
|
+
const { wss, workerWss } = createWebSockets(ctx);
|
|
93
|
+
|
|
94
|
+
// dataDir 的存在性与可写性已由 config.js 启动校验保证
|
|
95
|
+
await ctx.loadState();
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// HTTP 入口
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const server = http.createServer(async (req, res) => {
|
|
102
|
+
let pathname = '';
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(req.url, 'http://localhost');
|
|
105
|
+
pathname = url.pathname;
|
|
106
|
+
|
|
107
|
+
// 探活端点:反代/uptime 监控(Caddy health、Uptime Kuma 等)直接 GET 即可,
|
|
108
|
+
// 不参与签名鉴权,只回固定 { ok: true },不暴露任何状态与数据
|
|
109
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
110
|
+
return json(res, 200, { ok: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (pathname.startsWith('/api/')) {
|
|
114
|
+
// 验签失败限流:超阈值直接拒,不再读 body 做 HMAC,防止刷 401 耗资源
|
|
115
|
+
if (ctx.auth.authLimitExceeded(req.socket.remoteAddress)) {
|
|
116
|
+
return json(res, 429, { error: 'auth failures rate limited' });
|
|
117
|
+
}
|
|
118
|
+
// 先验签(读原始 body 计算摘要),通过后才进入业务路由;
|
|
119
|
+
// 密钥按路由角色选择(见 secretsForPath)
|
|
120
|
+
const auth = await ctx.auth.verifyRestSignature(req, ctx.auth.secretsForPath(pathname));
|
|
121
|
+
if (!auth.ok) {
|
|
122
|
+
ctx.auth.recordAuthFailure(req.socket.remoteAddress);
|
|
123
|
+
console.warn(
|
|
124
|
+
`[server] 拒绝请求 ${req.method} ${pathname},来源 ${req.socket.remoteAddress}:${auth.reason}`,
|
|
125
|
+
);
|
|
126
|
+
return json(res, 401, { error: 'unauthorized' });
|
|
127
|
+
}
|
|
128
|
+
return await ctx.handleApi(req, res, pathname, auth.rawBody, auth.workerId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (req.method === 'GET') return serveStatic(req, res, pathname);
|
|
132
|
+
|
|
133
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const statusCode = err.statusCode || (err.message === 'invalid json' ? 400 : 500);
|
|
136
|
+
if (statusCode >= 500) {
|
|
137
|
+
// 内部异常只记服务端日志,响应不外泄细节(可能含路径等内部信息)
|
|
138
|
+
console.error(`[server] 请求处理异常 ${req.method} ${pathname}: ${err.stack || err.message}`);
|
|
139
|
+
return json(res, 500, { error: 'internal error' });
|
|
140
|
+
}
|
|
141
|
+
return json(res, statusCode, { error: err.message || 'bad request' });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 两条通道共用同一 HTTP server,按路径分发 upgrade(path 模式的多个
|
|
146
|
+
// WebSocketServer 实例会在 upgrade 事件上互相干扰,故用 noServer 手动路由)
|
|
147
|
+
server.on('upgrade', (req, socket, head) => {
|
|
148
|
+
const pathname = new URL(req.url, 'http://localhost').pathname;
|
|
149
|
+
if (pathname === '/ws') {
|
|
150
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
151
|
+
} else if (pathname === '/worker/ws') {
|
|
152
|
+
workerWss.handleUpgrade(req, socket, head, (ws) => workerWss.emit('connection', ws, req));
|
|
153
|
+
} else {
|
|
154
|
+
socket.destroy();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 心跳探活:揪出半开连接(手机锁屏/断网后 TCP 可能不触发 close);
|
|
159
|
+
// 顺带回收空闲的 pty 会话
|
|
160
|
+
setInterval(() => {
|
|
161
|
+
for (const ws of [...wss.clients, ...workerWss.clients]) {
|
|
162
|
+
if (!ws.isAlive) {
|
|
163
|
+
ws.terminate();
|
|
164
|
+
ctx.wsClients.delete(ws);
|
|
165
|
+
ctx.wsWorkers.delete(ws);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
ws.isAlive = false;
|
|
169
|
+
ws.ping();
|
|
170
|
+
}
|
|
171
|
+
ctx.sweepPtyIdle();
|
|
172
|
+
}, 30 * 1000).unref();
|
|
173
|
+
|
|
174
|
+
server.listen(cfg.port, () => {
|
|
175
|
+
console.log(`[server] kite API listening on :${cfg.port}`);
|
|
176
|
+
console.log(`[server] config file: ${cfg.configFile}`);
|
|
177
|
+
console.log(`[server] data dir: ${cfg.dataDir}`);
|
|
178
|
+
console.log(`[server] log file: ${cfg.logFile}`);
|
|
179
|
+
const boundEntries = cfg.workerAuth.filter((e) => e.workerId);
|
|
180
|
+
const sharedEntry = cfg.workerAuth.find((e) => !e.workerId);
|
|
181
|
+
let authMode;
|
|
182
|
+
if (boundEntries.length) {
|
|
183
|
+
authMode = `多 Worker 密钥(${boundEntries.map((e) => e.workerId).join(', ')}` +
|
|
184
|
+
`${sharedEntry ? ',另有共享 workerSecret' : ''})`;
|
|
185
|
+
} else if (sharedEntry && sharedEntry.secret === cfg.apiSecret) {
|
|
186
|
+
authMode = '单密钥(控制台与 Worker 共用 apiSecret)';
|
|
187
|
+
} else {
|
|
188
|
+
authMode = '双密钥(控制台/Worker 已分离)';
|
|
189
|
+
}
|
|
190
|
+
console.log(`[server] auth mode: ${authMode}`);
|
|
191
|
+
});
|
package/config.example.yaml
CHANGED
|
@@ -26,9 +26,19 @@ workerSecret: ""
|
|
|
26
26
|
# 监听端口
|
|
27
27
|
port: 8787
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
dataDir:
|
|
29
|
+
# 持久化目录(~/.kites 标准树的 data/);建议写绝对路径;相对路径相对启动
|
|
30
|
+
# 工作目录解析,支持 ~ 展开;留空/删掉该行则默认 ~/.kites/data
|
|
31
|
+
dataDir: ~/.kites/data
|
|
32
|
+
|
|
33
|
+
# 程序自写日志文件(~/.kites 标准树的 logs/);stdout 输出不受影响;
|
|
34
|
+
# 超 10MB 轮转并逐代顺延(server.log.1 最新);留空/删掉该行则默认 ~/.kites/logs/server.log
|
|
35
|
+
logFile: ~/.kites/logs/server.log
|
|
36
|
+
|
|
37
|
+
# 日志落盘最低级别(debug|info|warn|error);只过滤落盘,前台输出不受影响
|
|
38
|
+
logLevel: info
|
|
39
|
+
|
|
40
|
+
# 轮转保留代数(server.log.1 … server.log.N),超出自动删除最旧一代
|
|
41
|
+
logKeep: 5
|
|
32
42
|
|
|
33
43
|
# 终态(done/failed/canceled)任务最多保留条数
|
|
34
44
|
maxTasks: 200
|
|
@@ -11,8 +11,8 @@ After=network.target
|
|
|
11
11
|
[Service]
|
|
12
12
|
Type=simple
|
|
13
13
|
User=www-data
|
|
14
|
-
#
|
|
15
|
-
WorkingDirectory=/
|
|
14
|
+
# ~/.kites 标准树(conf/data/logs)的家;unit 以 www-data 运行,家目录即 /var/www/.kites
|
|
15
|
+
WorkingDirectory=/var/www/.kites
|
|
16
16
|
# 配置只认 --config 传入的 YAML 文件(不再读环境变量),模板见包内 config.example.yaml;
|
|
17
17
|
# 启动服务必须显式走 start 子命令(裸调用只打印用法不会启动)
|
|
18
18
|
ExecStart=__KITES_BIN__ start --config __CONFIG_FILE__
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 路由:Worker 领取/心跳/上报,控制台建任务/查询/取消/关终端。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { json, parseBody, publicTask } from './util.js';
|
|
7
|
+
import { workersSnapshot } from './summary.js';
|
|
8
|
+
|
|
9
|
+
const MAX_LOG_LINES = 2000;
|
|
10
|
+
// pending 任务数上限:密钥泄露/控制台失守时防无限灌任务撑爆内存与 tasks.json
|
|
11
|
+
// (pruneFinishedTasks 只清终态,堆积的 pending 没有任何自然出口)
|
|
12
|
+
const MAX_PENDING_TASKS = 100;
|
|
13
|
+
|
|
14
|
+
export function createApi(ctx) {
|
|
15
|
+
// 身份绑定:配置 workerSecrets 时验签密钥即决定 Worker 身份;
|
|
16
|
+
// 自报 workerId 与绑定身份不一致返回 null,调用方按 401 拒绝
|
|
17
|
+
function resolveWorkerId(authedWorkerId, bodyWorkerId) {
|
|
18
|
+
const claimed = String(bodyWorkerId || 'unknown').slice(0, 128);
|
|
19
|
+
if (authedWorkerId && claimed !== authedWorkerId) return null;
|
|
20
|
+
return authedWorkerId || claimed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 归属校验(仅身份绑定生效时启用):只允许领取者上报日志/结果,
|
|
24
|
+
// 防止其他 Worker 伪造他人任务(含未领取任务)的日志与终态;
|
|
25
|
+
// 共享密钥模式(authedWorkerId 为 null)维持旧行为
|
|
26
|
+
function canReportTask(task, authedWorkerId) {
|
|
27
|
+
return !authedWorkerId || task.workerId === authedWorkerId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function handleApi(req, res, pathname, rawBody, authedWorkerId) {
|
|
31
|
+
ctx.sweepStaleRunning();
|
|
32
|
+
const body = parseBody(rawBody);
|
|
33
|
+
|
|
34
|
+
// --- Worker:领取最老的 pending 任务(原子置为 running)---
|
|
35
|
+
if (req.method === 'POST' && pathname === '/api/worker/claim') {
|
|
36
|
+
const workerId = resolveWorkerId(authedWorkerId, body.workerId);
|
|
37
|
+
if (!workerId) return json(res, 401, { error: 'workerId 与密钥绑定的身份不一致' });
|
|
38
|
+
// 显式指派模型:只领取指名给本 Worker 的任务,Worker 之间不存在竞争
|
|
39
|
+
const task = ctx.state.tasks.find(
|
|
40
|
+
(t) => t.status === 'pending' && t.targetWorker === workerId,
|
|
41
|
+
);
|
|
42
|
+
if (!task) return json(res, 200, { task: null });
|
|
43
|
+
task.status = 'running';
|
|
44
|
+
task.workerId = workerId;
|
|
45
|
+
task.startedAt = Date.now();
|
|
46
|
+
ctx.scheduleSave();
|
|
47
|
+
ctx.broadcast({ type: 'task.updated', task: ctx.taskSummary(task) });
|
|
48
|
+
return json(res, 200, { task });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- Worker:心跳 ---
|
|
52
|
+
if (req.method === 'POST' && pathname === '/api/worker/heartbeat') {
|
|
53
|
+
const workerId = resolveWorkerId(authedWorkerId, body.workerId);
|
|
54
|
+
if (!workerId) return json(res, 401, { error: 'workerId 与密钥绑定的身份不一致' });
|
|
55
|
+
ctx.state.heartbeats[workerId] = {
|
|
56
|
+
at: Date.now(),
|
|
57
|
+
hostname: String(body.hostname || '').slice(0, 128),
|
|
58
|
+
node: String(body.node || '').slice(0, 64),
|
|
59
|
+
// workspace 下的项目目录列表(控制台据此渲染终端起始目录下拉)
|
|
60
|
+
projects: Array.isArray(body.projects)
|
|
61
|
+
? body.projects.map((p) => String(p).slice(0, 64)).slice(0, 64)
|
|
62
|
+
: [],
|
|
63
|
+
};
|
|
64
|
+
ctx.scheduleSave();
|
|
65
|
+
ctx.broadcast({ type: 'workers', workers: workersSnapshot(ctx.state.heartbeats) });
|
|
66
|
+
return json(res, 200, { ok: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Worker / 控制台:查询 Worker 在线状态 ---
|
|
70
|
+
if (req.method === 'GET' && pathname === '/api/worker/status') {
|
|
71
|
+
return json(res, 200, { workers: workersSnapshot(ctx.state.heartbeats) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- 创建任务(pending 超限拒绝,见 MAX_PENDING_TASKS)---
|
|
75
|
+
if (req.method === 'POST' && pathname === '/api/tasks') {
|
|
76
|
+
const type = String(body.type || '');
|
|
77
|
+
if (type !== 'pty') {
|
|
78
|
+
return json(res, 400, { error: 'type 必须是 pty' });
|
|
79
|
+
}
|
|
80
|
+
const pendingCount = ctx.state.tasks.filter((t) => t.status === 'pending').length;
|
|
81
|
+
if (pendingCount >= MAX_PENDING_TASKS) {
|
|
82
|
+
return json(res, 429, { error: `待执行任务已达上限(${MAX_PENDING_TASKS}),请先消化积压` });
|
|
83
|
+
}
|
|
84
|
+
// 显式指派模型:targetWorker 必填且必须是已注册(心跳过)的 Worker,
|
|
85
|
+
// 杜绝无人可领的孤儿任务永远 pending
|
|
86
|
+
const tw = String(body.targetWorker || '').trim().slice(0, 128);
|
|
87
|
+
if (!tw) {
|
|
88
|
+
return json(res, 400, { error: '必须指定目标 Worker(targetWorker)' });
|
|
89
|
+
}
|
|
90
|
+
if (!/^[\w.-]{1,128}$/.test(tw)) {
|
|
91
|
+
return json(res, 400, { error: 'targetWorker 只能含字母/数字/._-' });
|
|
92
|
+
}
|
|
93
|
+
if (!ctx.state.heartbeats[tw]) {
|
|
94
|
+
return json(res, 400, { error: `Worker "${tw}" 从未注册,无法指派` });
|
|
95
|
+
}
|
|
96
|
+
const targetWorker = tw;
|
|
97
|
+
const task = {
|
|
98
|
+
id: crypto.randomUUID(),
|
|
99
|
+
type,
|
|
100
|
+
title: String(body.title || '').slice(0, 200),
|
|
101
|
+
payload: body.payload || {},
|
|
102
|
+
status: 'pending',
|
|
103
|
+
logs: [],
|
|
104
|
+
result: null,
|
|
105
|
+
createdAt: Date.now(),
|
|
106
|
+
startedAt: null,
|
|
107
|
+
finishedAt: null,
|
|
108
|
+
workerId: null,
|
|
109
|
+
targetWorker,
|
|
110
|
+
};
|
|
111
|
+
ctx.state.tasks.push(task);
|
|
112
|
+
ctx.scheduleSave();
|
|
113
|
+
ctx.broadcast({ type: 'task.created', task: ctx.taskSummary(task) });
|
|
114
|
+
ctx.notifyWorkers(targetWorker); // 任务到达即推送,Worker 立即领取,不再等轮询周期
|
|
115
|
+
return json(res, 201, publicTask(task));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- 任务列表 ---
|
|
119
|
+
if (req.method === 'GET' && pathname === '/api/tasks') {
|
|
120
|
+
const url = new URL(req.url, 'http://localhost');
|
|
121
|
+
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 50, 200));
|
|
122
|
+
const list = [...ctx.state.tasks]
|
|
123
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
124
|
+
.slice(0, limit)
|
|
125
|
+
.map(ctx.taskSummary);
|
|
126
|
+
return json(res, 200, { tasks: list });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- 单个任务详情(含日志)---
|
|
130
|
+
const taskMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})$/);
|
|
131
|
+
if (req.method === 'GET' && taskMatch) {
|
|
132
|
+
const task = ctx.state.tasks.find((t) => t.id === taskMatch[1]);
|
|
133
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
134
|
+
return json(res, 200, publicTask(task));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- 追加日志(Worker)---
|
|
138
|
+
const logMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/logs$/);
|
|
139
|
+
if (req.method === 'POST' && logMatch) {
|
|
140
|
+
const task = ctx.state.tasks.find((t) => t.id === logMatch[1]);
|
|
141
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
142
|
+
if (!canReportTask(task, authedWorkerId)) {
|
|
143
|
+
return json(res, 403, { error: '只能上报自己领取的任务' });
|
|
144
|
+
}
|
|
145
|
+
const lines = Array.isArray(body.lines) ? body.lines : [];
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
task.logs.push(String(line).slice(0, 4000));
|
|
148
|
+
}
|
|
149
|
+
if (task.logs.length > MAX_LOG_LINES) {
|
|
150
|
+
task.logs.splice(0, task.logs.length - MAX_LOG_LINES);
|
|
151
|
+
}
|
|
152
|
+
ctx.scheduleSave();
|
|
153
|
+
ctx.broadcast({ type: 'task.logs', id: task.id, lines });
|
|
154
|
+
return json(res, 200, { ok: true, logCount: task.logs.length });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- 结束任务(Worker)---
|
|
158
|
+
const finishMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/finish$/);
|
|
159
|
+
if (req.method === 'POST' && finishMatch) {
|
|
160
|
+
const task = ctx.state.tasks.find((t) => t.id === finishMatch[1]);
|
|
161
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
162
|
+
if (!canReportTask(task, authedWorkerId)) {
|
|
163
|
+
return json(res, 403, { error: '只能上报自己领取的任务' });
|
|
164
|
+
}
|
|
165
|
+
// 状态守卫:已到终态(被 sweepStaleRunning 判失败回收、控制台取消等)的任务
|
|
166
|
+
// 拒绝迟到的 finish,防止短暂失联后"复活"的 Worker 把终态翻回
|
|
167
|
+
if (task.status !== 'running') {
|
|
168
|
+
return json(res, 409, { error: `任务已处于终态(${task.status}),拒绝 finish 上报` });
|
|
169
|
+
}
|
|
170
|
+
task.status = body.status === 'done' ? 'done' : 'failed';
|
|
171
|
+
task.result = body.result ?? null;
|
|
172
|
+
task.finishedAt = Date.now();
|
|
173
|
+
ctx.scheduleSave();
|
|
174
|
+
ctx.broadcast({ type: 'task.updated', task: ctx.taskSummary(task), result: task.result });
|
|
175
|
+
ctx.notifyIm(task, task.status);
|
|
176
|
+
return json(res, 200, publicTask(task));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- 取消任务(控制台;仅对 pending 生效)---
|
|
180
|
+
const cancelMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/cancel$/);
|
|
181
|
+
if (req.method === 'POST' && cancelMatch) {
|
|
182
|
+
const task = ctx.state.tasks.find((t) => t.id === cancelMatch[1]);
|
|
183
|
+
if (!task) return json(res, 404, { error: 'task not found' });
|
|
184
|
+
if (task.status !== 'pending') {
|
|
185
|
+
return json(res, 409, { error: `只能取消 pending 任务,当前状态:${task.status}` });
|
|
186
|
+
}
|
|
187
|
+
task.status = 'canceled';
|
|
188
|
+
task.finishedAt = Date.now();
|
|
189
|
+
ctx.scheduleSave();
|
|
190
|
+
ctx.broadcast({ type: 'task.updated', task: ctx.taskSummary(task) });
|
|
191
|
+
ctx.notifyIm(task, 'canceled');
|
|
192
|
+
return json(res, 200, publicTask(task));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- 关闭交互终端(控制台):通知持有会话的 Worker 杀掉 PTY,
|
|
196
|
+
// 任务终态仍由 Worker 的 finish 上报闭环 ---
|
|
197
|
+
const killMatch = pathname.match(/^\/api\/tasks\/([0-9a-f-]{36})\/kill$/);
|
|
198
|
+
if (req.method === 'POST' && killMatch) {
|
|
199
|
+
const s = ctx.ptySessions.get(killMatch[1]);
|
|
200
|
+
if (!s) return json(res, 404, { error: '没有活跃的终端会话' });
|
|
201
|
+
ctx.wsSendJson(s.ws, { type: 'session.close', taskId: killMatch[1] });
|
|
202
|
+
return json(res, 200, { ok: true });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return json(res, 404, { error: 'not found' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return handleApi;
|
|
209
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 签名鉴权:HMAC-SHA256(密钥, METHOD\nPATH\nTS\nNONCE\nSHA256(BODY))
|
|
3
|
+
* 密钥永不上网;时间窗 + nonce 缓存联合防重放。
|
|
4
|
+
* 密钥按路由角色选择:控制台 API 用 apiSecret,Worker API 用 workerAuth
|
|
5
|
+
* 条目列表(workerSecrets 绑定密钥 / 共享 workerSecret / 回落 apiSecret)。
|
|
6
|
+
* 另含验签失败限流。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { timingSafeEqualStr } from './util.js';
|
|
11
|
+
|
|
12
|
+
export function createAuth(ctx) {
|
|
13
|
+
const { apiSecret: API_SECRET, workerAuth: WORKER_AUTH, sigWindowSec: SIG_WINDOW_SEC } = ctx.cfg;
|
|
14
|
+
|
|
15
|
+
const seenNonces = new Map(); // nonce -> 过期时间戳(ms)
|
|
16
|
+
|
|
17
|
+
function pruneNonces(now) {
|
|
18
|
+
if (seenNonces.size < 10000) return;
|
|
19
|
+
for (const [nonce, expiry] of seenNonces) {
|
|
20
|
+
if (expiry < now) seenNonces.delete(nonce);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 校验 ts/nonce/签名三元组;通过即消费掉该 nonce。
|
|
25
|
+
// signingParts 为参与签名的各行(不含密钥),body 为原始字节(可为空 Buffer)。
|
|
26
|
+
function verifySignature(signingParts, tsRaw, nonce, sig, secret) {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
if (!tsRaw || !nonce || !sig) {
|
|
29
|
+
return { ok: false, reason: '缺少签名头 X-RW-Ts/X-RW-Nonce/X-RW-Sig' };
|
|
30
|
+
}
|
|
31
|
+
const ts = Number(tsRaw);
|
|
32
|
+
if (!Number.isFinite(ts)) return { ok: false, reason: `时间戳非法: ${String(tsRaw).slice(0, 32)}` };
|
|
33
|
+
const skewSec = Math.abs(now / 1000 - ts);
|
|
34
|
+
if (skewSec > SIG_WINDOW_SEC) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: `时间戳超出 ±${SIG_WINDOW_SEC}s 窗口(偏差 ${Math.round(skewSec)}s;检查客户端时钟)`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const nonceStr = String(nonce);
|
|
41
|
+
if (nonceStr.length < 8 || nonceStr.length > 64) {
|
|
42
|
+
return { ok: false, reason: 'nonce 长度非法' };
|
|
43
|
+
}
|
|
44
|
+
if (seenNonces.has(nonceStr)) {
|
|
45
|
+
return { ok: false, reason: 'nonce 重复(疑似重放)' };
|
|
46
|
+
}
|
|
47
|
+
const signingString = signingParts.join('\n');
|
|
48
|
+
const expected = crypto.createHmac('sha256', secret).update(signingString).digest('hex');
|
|
49
|
+
if (!timingSafeEqualStr(String(sig), expected)) {
|
|
50
|
+
return { ok: false, reason: '签名不匹配(密钥错误或请求被篡改)' };
|
|
51
|
+
}
|
|
52
|
+
seenNonces.set(nonceStr, now + SIG_WINDOW_SEC * 1000);
|
|
53
|
+
pruneNonces(now);
|
|
54
|
+
return { ok: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readRawBody(req, limit = 1024 * 1024) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let size = 0;
|
|
60
|
+
const chunks = [];
|
|
61
|
+
req.on('data', (chunk) => {
|
|
62
|
+
size += chunk.length;
|
|
63
|
+
if (size > limit) {
|
|
64
|
+
const err = new Error('body too large');
|
|
65
|
+
err.statusCode = 413; // 明确为客户端错误,响应里可安全回显
|
|
66
|
+
reject(err);
|
|
67
|
+
req.destroy();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
});
|
|
72
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
73
|
+
req.on('error', reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// REST 请求验签:签名覆盖方法、路径、时间戳、nonce 与请求体摘要。
|
|
78
|
+
// 注意:路径用含 query 的完整形式(与客户端签名方式一致,防止改写查询参数)。
|
|
79
|
+
// entries 为该路由可接受的密钥条目列表({ secret, workerId },按序尝试,
|
|
80
|
+
// 验过任一即通过);workerId 非 null 表示该密钥绑定了 Worker 身份
|
|
81
|
+
// (workerSecrets 条目),null 表示身份自报(共享密钥兼容路径)。
|
|
82
|
+
// nonce 只在验过之后消费,失败尝试不留副作用。
|
|
83
|
+
async function verifyRestSignature(req, entries) {
|
|
84
|
+
const rawBody = await readRawBody(req);
|
|
85
|
+
const bodySha = crypto.createHash('sha256').update(rawBody).digest('hex');
|
|
86
|
+
const parts = [req.method, req.url, req.headers['x-rw-ts'], req.headers['x-rw-nonce'], bodySha];
|
|
87
|
+
let result = { ok: false, reason: '缺少签名头 X-RW-Ts/X-RW-Nonce/X-RW-Sig' };
|
|
88
|
+
let workerId = null;
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
result = verifySignature(parts, req.headers['x-rw-ts'], req.headers['x-rw-nonce'], req.headers['x-rw-sig'], entry.secret);
|
|
91
|
+
if (result.ok) {
|
|
92
|
+
workerId = entry.workerId;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { ...result, rawBody, workerId };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 路由 -> 可接受的密钥条目:Worker 侧 API 只认 WORKER_AUTH 条目,控制台只认
|
|
100
|
+
// API_SECRET;worker/status 两端都要读(控制台轮询兜底也用),两边密钥都放行
|
|
101
|
+
const WORKER_PATH = /^\/api\/(worker\/(claim|heartbeat)|tasks\/[0-9a-f-]{36}\/(logs|finish))$/;
|
|
102
|
+
function secretsForPath(pathname) {
|
|
103
|
+
if (pathname === '/api/worker/status') {
|
|
104
|
+
return [{ secret: API_SECRET, workerId: null }, ...WORKER_AUTH];
|
|
105
|
+
}
|
|
106
|
+
return WORKER_PATH.test(pathname) ? WORKER_AUTH : [{ secret: API_SECRET, workerId: null }];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 验签失败限流:单 IP 滑动窗口(60s/30 次),防止恶意刷 401 灌日志耗 CPU;
|
|
110
|
+
// 只统计失败,成功请求不计(正常轮询频率不会触限)
|
|
111
|
+
const authFailures = new Map(); // ip -> { count, windowStart }
|
|
112
|
+
function authLimitExceeded(ip) {
|
|
113
|
+
const rec = authFailures.get(ip);
|
|
114
|
+
return Boolean(rec && Date.now() - rec.windowStart <= 60_000 && rec.count >= 30);
|
|
115
|
+
}
|
|
116
|
+
function recordAuthFailure(ip) {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (authFailures.size > 10_000) {
|
|
119
|
+
for (const [k, rec] of authFailures) if (now - rec.windowStart > 60_000) authFailures.delete(k);
|
|
120
|
+
}
|
|
121
|
+
let rec = authFailures.get(ip);
|
|
122
|
+
if (!rec || now - rec.windowStart > 60_000) {
|
|
123
|
+
rec = { count: 0, windowStart: now };
|
|
124
|
+
authFailures.set(ip, rec);
|
|
125
|
+
}
|
|
126
|
+
rec.count++;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { verifySignature, verifyRestSignature, secretsForPath, authLimitExceeded, recordAuthFailure };
|
|
130
|
+
}
|