@jacksontian/kite-server 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/config.example.yaml +18 -2
- package/config.js +340 -5
- package/deploy/kiteserver.service +24 -0
- package/package.json +5 -1
- package/server.js +227 -45
- package/web/index.html +95 -13
- package/web/pc.html +878 -0
- package/web/vendor/addon-fit.min.js +8 -0
- package/web/vendor/xterm.min.css +8 -0
- package/web/vendor/xterm.min.js +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# @jacksontian/kite-server
|
|
2
|
+
|
|
3
|
+
[kite](https://github.com/jacksontian/remote-work) 的云端信箱组件:部署在你的云服务器(VPS)上,
|
|
4
|
+
提供 HMAC 签名鉴权的任务队列 API、WebSocket 实时推送和 Web 控制台(手机/PC 双版)。
|
|
5
|
+
|
|
6
|
+
整体架构、端到端部署流程见仓库根 README;本文只讲 server 这一侧。
|
|
7
|
+
|
|
8
|
+
## 职责
|
|
9
|
+
|
|
10
|
+
- 接收控制台提交的任务,持久化到 `dataDir`(任务队列 / 日志 / 结果)
|
|
11
|
+
- 通过 `/worker/ws` 出站长连接向 Worker 即时推送新任务,Worker 不监听任何端口
|
|
12
|
+
- 托管 Web 控制台(`web/` 随包分发),浏览器打开即用;首页按 UA 自动分发
|
|
13
|
+
手机版(`index.html`)/ PC 版(`pc.html`),也可显式访问对应路径
|
|
14
|
+
- 任务终态可选推送到钉钉群机器人(`imWebhook`)
|
|
15
|
+
|
|
16
|
+
## 安装与启动
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm i -g @jacksontian/kite-server
|
|
20
|
+
|
|
21
|
+
# 一步生成配置文件(自动填好随机 apiSecret/workerSecret,打印在输出里请保存)
|
|
22
|
+
kites init # 默认生成在当前目录(server.yaml);生产部署用 kites init /opt/kite/server.yaml
|
|
23
|
+
# 手工方式:cp "$(npm root -g)/@jacksontian/kite-server/config.example.yaml" /opt/kite/server.yaml 后编辑,
|
|
24
|
+
# 密钥用 kites keygen 或下面命令生成(永不在网络上传输):
|
|
25
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
26
|
+
|
|
27
|
+
kites check --config /opt/kite/server.yaml # 干跑校验:只检查配置不启动服务,通过后退出
|
|
28
|
+
kites start --config /opt/kite/server.yaml # 前台启动服务;常驻托管交给 systemd(见下文 deploy)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
启动后浏览器打开 `http://<vps>:8787`,粘贴密钥即可进入控制台。
|
|
32
|
+
配置统一走 YAML 文件,以 `--config` 显式传入,不读取环境变量/.env。
|
|
33
|
+
`kites` 是完全命令行化的工具:启动服务必须显式 `kites start`,裸 `kites`
|
|
34
|
+
(不带子命令)只打印用法、不会启动服务。`kites --help` 查看完整用法,
|
|
35
|
+
`kites --version` 查看版本号。
|
|
36
|
+
|
|
37
|
+
## 配置项
|
|
38
|
+
|
|
39
|
+
模板见包内 `config.example.yaml`:
|
|
40
|
+
|
|
41
|
+
| 配置 | 说明 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `apiSecret` | 必填,两端共享的 HMAC 签名密钥 |
|
|
44
|
+
| `port` | 监听端口,默认 8787 |
|
|
45
|
+
| `dataDir` | 持久化目录,建议绝对路径,支持 `~` 展开,默认 `~/.kite/data` |
|
|
46
|
+
| `maxTasks` | 终态任务最多保留条数 |
|
|
47
|
+
| `staleRunningSec` | running 任务超时回收秒数(Worker 离线时判失败,pty 会话不受此限) |
|
|
48
|
+
| `sigWindowSec` | 签名时间窗(秒),两端时钟漂移大时可调宽 |
|
|
49
|
+
| `ptyIdleSec` | pty 会话空闲回收秒数 |
|
|
50
|
+
| `imWebhook` / `imSecret` / `imNotifyEvents` | 钉钉群机器人通知,留空则完全静默 |
|
|
51
|
+
|
|
52
|
+
## 生产部署
|
|
53
|
+
|
|
54
|
+
- systemd unit:`kites deploy` 一步生成占位符已替换好的 unit(包内模板
|
|
55
|
+
`deploy/kiteserver.service`),再 `sudo install` 到 /etc/systemd/system/;
|
|
56
|
+
unit 以 `www-data` 运行,需给它 `dataDir` 写权限与配置文件读权限
|
|
57
|
+
- 建议 Nginx/Caddy 反代加 HTTPS(Caddy 有域名即自动签证书);仅 HTTP 部署时
|
|
58
|
+
按根 README「仅 HTTP 部署」一节做 IP 白名单与密钥轮换
|
|
59
|
+
- `/health` 免签名探活端点,GET 返回 `{ ok: true }`,供 Caddy upstream health /
|
|
60
|
+
Uptime Kuma 等监控直接探测
|
|
61
|
+
|
|
62
|
+
## 依赖
|
|
63
|
+
|
|
64
|
+
Node ≥ 24;运行时依赖 `ws`、`yaml`、`zod`、`dingbot`,随包自动安装。
|
|
65
|
+
|
|
66
|
+
## 配套组件
|
|
67
|
+
|
|
68
|
+
Worker 端为 [`@jacksontian/kite-worker`](https://www.npmjs.com/package/@jacksontian/kite-worker),
|
|
69
|
+
部署在 Mac Mini 等内网机器上,只发起出站连接到本服务。
|
package/config.example.yaml
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
# kite 云端 server 配置模板。
|
|
2
2
|
# 复制为实际配置文件(如 server.yaml)后填写,启动时显式传入:
|
|
3
|
-
#
|
|
3
|
+
# kites start --config /path/to/server.yaml
|
|
4
4
|
# 实际配置文件含密钥,不要提交到 git。
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# 必填:控制台签名密钥(永不在网络上传输)。生成(二选一):
|
|
7
|
+
# kites keygen # 推荐:生成并打印配置片段
|
|
7
8
|
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
8
9
|
apiSecret: 换成你生成的密钥
|
|
9
10
|
|
|
11
|
+
# 可选:Worker 专用密钥(建议另生成一个不同的)。配置后 Worker 侧 API
|
|
12
|
+
# (claim/heartbeat/logs/finish)与 /worker/ws 只认该密钥,控制台密钥泄露
|
|
13
|
+
# 不再等于 Worker 执行权;留空则两端共用 apiSecret。
|
|
14
|
+
# Worker 端 worker.yaml 的 apiSecret 需与此处一致
|
|
15
|
+
workerSecret: ""
|
|
16
|
+
|
|
17
|
+
# 可选:多 Worker 独立密钥(workerId -> 密钥映射,每台 Worker 一个,
|
|
18
|
+
# 各用 kites keygen 生成)。配置后服务端把 Worker 身份绑定到验签命中的密钥:
|
|
19
|
+
# 自报 workerId 必须与映射键一致,Worker 之间无法互相领取定向任务/伪造上报。
|
|
20
|
+
# 各 Worker 的 worker.yaml 里 workerId 与映射键一致、apiSecret 填对应密钥。
|
|
21
|
+
# 可与上面的 workerSecret 并存(命中共享密钥时身份仍自报,兼容旧部署)
|
|
22
|
+
# workerSecrets:
|
|
23
|
+
# mac-mini: 换成你生成的密钥1
|
|
24
|
+
# home-nas: 换成你生成的密钥2
|
|
25
|
+
|
|
10
26
|
# 监听端口
|
|
11
27
|
port: 8787
|
|
12
28
|
|
package/config.js
CHANGED
|
@@ -1,22 +1,111 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 云端 server 配置加载与校验。
|
|
3
3
|
*
|
|
4
|
+
* 纯 CLI 形态:任何动作都走子命令,裸 kites(无参数)只打印用法、绝不启动服务。
|
|
4
5
|
* 只认 --config 指定的 YAML 配置文件(不再读取任何环境变量):
|
|
5
|
-
*
|
|
6
|
+
* kites start --config /path/to/server.yaml
|
|
6
7
|
* 结构与类型校验由 zod schema 完成(CONFIG_SCHEMA):未知配置项、
|
|
7
8
|
* 类型错误、缺必填项一次性报全后 exit 1;dataDir 的可创建/可写校验
|
|
8
9
|
* 是运行时行为,仍在校验通过后单独执行。模板见包内 config.example.yaml。
|
|
10
|
+
*
|
|
11
|
+
* 另支持子命令与常规 CLI 选项:
|
|
12
|
+
* kites start 启动云端服务(--config 必填);裸 kites 不带子命令只打印用法,不启动
|
|
13
|
+
* kites keygen 生成强随机密钥并打印配置片段
|
|
14
|
+
* kites init [路径] 在目标位置(默认当前目录 server.yaml)生成填好随机密钥的配置文件
|
|
15
|
+
* kites check 只校验 --config 指定的配置文件,通过打印摘要退出,不启动服务
|
|
16
|
+
* kites deploy 基于包内 systemd 模板生成占位符已按本机替换好的 unit 文件
|
|
17
|
+
* kites --help 打印用法说明(-h 等效)
|
|
18
|
+
* kites --version 打印版本号(-V 等效)
|
|
19
|
+
* apiSecret 若仍是配置模板的占位符文本,启动时直接拦截,避免裸奔。
|
|
9
20
|
*/
|
|
10
21
|
|
|
11
22
|
import fs from 'node:fs';
|
|
12
23
|
import os from 'node:os';
|
|
13
24
|
import path from 'node:path';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import cp from 'node:child_process';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
14
28
|
import YAML from 'yaml';
|
|
15
29
|
import { z } from 'zod';
|
|
16
30
|
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
/** 用法说明:--help/-h 输出到 stdout 并 exit 0(符合 CLI 惯例);
|
|
34
|
+
* 裸 kites(不带任何子命令)同样只打印用法,不启动服务 */
|
|
35
|
+
function printHelp() {
|
|
36
|
+
console.log('用法:kites <子命令> [选项]');
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log('子命令:');
|
|
39
|
+
console.log(' kites start 启动云端服务(前台运行;常驻托管交给 systemd,见 deploy)');
|
|
40
|
+
console.log(' -c, --config <路径> 指定 YAML 配置文件(必填,模板见包内 config.example.yaml)');
|
|
41
|
+
console.log(' kites check 只校验 --config 指定的配置文件(改配置后干跑验证),通过即退出,不启动服务');
|
|
42
|
+
console.log(' kites keygen 生成强随机密钥并打印配置片段');
|
|
43
|
+
console.log(' kites init [路径] 基于包内模板生成配置文件(默认当前目录 server.yaml),自动填好随机密钥');
|
|
44
|
+
console.log(' kites deploy [输出] 基于包内 systemd 模板生成占位符已替换好的 unit 文件(默认当前目录 kiteserver.service),');
|
|
45
|
+
console.log(' 可选 --config <路径> 指定 unit 里的配置文件路径(默认 /opt/kite/server.yaml)');
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log('选项:');
|
|
48
|
+
console.log(' -h, --help 打印本帮助');
|
|
49
|
+
console.log(' -V, --version 打印版本号');
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log('不带任何子命令时只打印本用法,不会启动服务。');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** --version/-V:读取包内 package.json 输出版本号(与 npm 发布版本一致) */
|
|
55
|
+
function printVersion() {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
|
58
|
+
console.log(`kites (${pkg.name}) ${pkg.version}`);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(`[server] 无法读取包内 package.json:${err.message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 常规 CLI 选项:--help/-h 与 --version/-V 优先于子命令/配置加载,识别到即退出 */
|
|
66
|
+
function maybeMeta() {
|
|
19
67
|
const args = process.argv.slice(2);
|
|
68
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
69
|
+
printHelp();
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
if (args.includes('--version') || args.includes('-V')) {
|
|
73
|
+
printVersion();
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SUBCOMMANDS = ['start', 'check', 'keygen', 'init', 'deploy'];
|
|
79
|
+
|
|
80
|
+
/** 子命令分发:返回 argv[2] 的子命令名。CLI 化的核心约定——启动服务必须显式
|
|
81
|
+
* 走 start 子命令;裸 kites(不带任何参数)只打印用法退出,绝不启动服务 */
|
|
82
|
+
function dispatch() {
|
|
83
|
+
const cmd = process.argv[2];
|
|
84
|
+
if (cmd === undefined) {
|
|
85
|
+
printHelp();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
if (SUBCOMMANDS.includes(cmd)) return cmd;
|
|
89
|
+
if (cmd === '--config' || cmd === '-c' || cmd.startsWith('--config=')) {
|
|
90
|
+
// 旧版写法(kites --config x):明确告知启动已改为 start 子命令,不静默兼容
|
|
91
|
+
console.error('[server] 启动服务请显式使用 start 子命令:kites start --config <配置文件路径>');
|
|
92
|
+
console.error('[server] 运行 kites --help 查看用法');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
console.error(`[server] 未知子命令 "${cmd}";运行 kites --help 查看用法`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** check 子命令位参数(argv[2] === 'check'):其余照常解析,校验通过后在
|
|
100
|
+
* loadServerConfig 末尾打印摘要退出,不真正启动服务 */
|
|
101
|
+
function isCheckMode() {
|
|
102
|
+
return process.argv[2] === 'check';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 解析 start/check 子命令后的参数;--config 必填,缺失/重复/取不到值/出现未知参数立即退出 */
|
|
106
|
+
function parseArgs() {
|
|
107
|
+
const sub = process.argv[2]; // dispatch 已保证是 start 或 check
|
|
108
|
+
const args = process.argv.slice(3);
|
|
20
109
|
const values = [];
|
|
21
110
|
for (let i = 0; i < args.length; i++) {
|
|
22
111
|
if (args[i] === '--config' || args[i] === '-c') {
|
|
@@ -24,11 +113,15 @@ function parseArgs() {
|
|
|
24
113
|
i++;
|
|
25
114
|
} else if (args[i].startsWith('--config=')) {
|
|
26
115
|
values.push(args[i].slice('--config='.length));
|
|
116
|
+
} else {
|
|
117
|
+
// 输错参数不再静默忽略:报出具体参数并指路 --help,避免误以为已生效
|
|
118
|
+
console.error(`[server] 未知参数 "${args[i]}";运行 kites --help 查看用法`);
|
|
119
|
+
process.exit(1);
|
|
27
120
|
}
|
|
28
121
|
}
|
|
29
122
|
if (values.length === 0) {
|
|
30
|
-
console.error(
|
|
31
|
-
console.error('[server]
|
|
123
|
+
console.error(`[server] 缺少 --config:kites ${sub} --config <配置文件路径>`);
|
|
124
|
+
console.error('[server] 运行 kites --help 查看用法;配置模板见包内 config.example.yaml');
|
|
32
125
|
process.exit(1);
|
|
33
126
|
}
|
|
34
127
|
if (values.length > 1) {
|
|
@@ -54,6 +147,168 @@ function isPlainObject(v) {
|
|
|
54
147
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
55
148
|
}
|
|
56
149
|
|
|
150
|
+
/** config.example.yaml 里的占位文本:原样未改就启动等于用公开"密钥"裸奔,必须拦下 */
|
|
151
|
+
const PLACEHOLDER_SECRET = '换成你生成的密钥';
|
|
152
|
+
|
|
153
|
+
/** keygen 子命令(kites keygen):生成强随机密钥并打印配置片段,免去手工生成/搬运 hex */
|
|
154
|
+
function maybeKeygen() {
|
|
155
|
+
if (process.argv[2] !== 'keygen') return;
|
|
156
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
157
|
+
console.log('[server] 已生成签名密钥(永不在网络上传输,请妥善保管):');
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(' ' + secret);
|
|
160
|
+
console.log('');
|
|
161
|
+
console.log('[server] 粘贴到 server.yaml(手机浏览器控制台使用同一密钥):');
|
|
162
|
+
console.log('');
|
|
163
|
+
console.log(' apiSecret: ' + secret);
|
|
164
|
+
console.log('');
|
|
165
|
+
console.log('[server] 建议再执行一次 kites keygen 生成不同的 workerSecret(分权见 README「安全说明」),');
|
|
166
|
+
console.log('[server] Worker 端 worker.yaml 的 apiSecret 与 workerSecret 保持一致');
|
|
167
|
+
process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** init/deploy 就跑在目标环境里,顺手解析 kites 可执行路径(等效 `which kites`),
|
|
171
|
+
* 好把 unit 安装命令里的 __KITES_BIN__ 占位符换成现成值;
|
|
172
|
+
* 取不到(如仓库源码里直接跑)则返回 null 由调用方决定回退方式 */
|
|
173
|
+
function resolveKitesBin() {
|
|
174
|
+
try {
|
|
175
|
+
const p = cp.execSync('which kites', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
176
|
+
if (p) return p;
|
|
177
|
+
} catch { /* PATH 里没有 kites */ }
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** init 子命令(kites init [配置文件路径]):基于包内模板一步生成配置文件,
|
|
182
|
+
* 自动填好随机 apiSecret/workerSecret,免去「复制模板 + keygen + 粘贴」三步;
|
|
183
|
+
* 默认生成在当前目录(好找),生产部署建议显式 init 到 /opt/kite/server.yaml;
|
|
184
|
+
* 已存在的目标绝不覆盖 */
|
|
185
|
+
function maybeInit() {
|
|
186
|
+
if (process.argv[2] !== 'init') return;
|
|
187
|
+
const rawPath = process.argv[3] || 'server.yaml';
|
|
188
|
+
if (!rawPath.trim() || rawPath.startsWith('-')) {
|
|
189
|
+
console.error('[server] init 用法:kites init [配置文件路径](默认当前目录 server.yaml)');
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
const target = path.resolve(expandHome(rawPath));
|
|
193
|
+
if (fs.existsSync(target)) {
|
|
194
|
+
console.error(`[server] ${target} 已存在,init 不会覆盖;如需重新生成请先备份删除或换路径`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
let tpl;
|
|
198
|
+
try {
|
|
199
|
+
tpl = fs.readFileSync(path.join(__dirname, 'config.example.yaml'), 'utf8');
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.error(`[server] 无法读取包内配置模板 config.example.yaml:${err.message}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
const apiSecret = crypto.randomBytes(32).toString('hex');
|
|
205
|
+
const workerSecret = crypto.randomBytes(32).toString('hex');
|
|
206
|
+
// dataDir 固化为配置文件同目录下的绝对路径,与启动工作目录解耦
|
|
207
|
+
const dataDir = path.join(path.dirname(target), 'data');
|
|
208
|
+
const content = tpl
|
|
209
|
+
.replace(`apiSecret: ${PLACEHOLDER_SECRET}`, `apiSecret: ${apiSecret}`)
|
|
210
|
+
.replace('workerSecret: ""', `workerSecret: ${workerSecret}`)
|
|
211
|
+
.replace('dataDir: ./data', `dataDir: ${dataDir}`);
|
|
212
|
+
if (!content.includes(apiSecret)) {
|
|
213
|
+
console.error('[server] 包内配置模板结构与预期不符(找不到占位符),请改用手工方式:复制 config.example.yaml 后填写');
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
218
|
+
fs.writeFileSync(target, content, { mode: 0o600 });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error(`[server] 无法写入 ${target}:${err.message}`);
|
|
221
|
+
console.error('[server] 目标目录需要 root 权限时请改用 sudo,或 init 到自定义路径');
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
console.log(`[server] 已生成配置:${target}(权限 0600)`);
|
|
225
|
+
console.log('');
|
|
226
|
+
console.log(` apiSecret(控制台/手机浏览器粘贴这个):${apiSecret}`);
|
|
227
|
+
console.log(` workerSecret(Worker 专用,填入 worker.yaml 的 apiSecret):${workerSecret}`);
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log('[server] 密钥已写入配置文件,请另行妥善保存(永不在网络上传输)');
|
|
230
|
+
console.log(`[server] 干跑验证:kites check --config ${target}`);
|
|
231
|
+
console.log(`[server] 启动:kites start --config ${target}`);
|
|
232
|
+
if (target.startsWith('/opt/kite')) {
|
|
233
|
+
console.log('[server] 生产部署后续步骤(systemd 以 www-data 运行):');
|
|
234
|
+
console.log(` sudo mkdir -p ${dataDir} && sudo chown -R www-data:www-data ${dataDir}`);
|
|
235
|
+
console.log(` sudo chown www-data:www-data ${target}`);
|
|
236
|
+
console.log(' 安装 systemd unit(kites deploy 自动按本机环境替换占位符,无需手工 sed):');
|
|
237
|
+
console.log(` kites deploy --config ${target}`);
|
|
238
|
+
console.log(' sudo install -m 644 kiteserver.service /etc/systemd/system/kiteserver.service');
|
|
239
|
+
console.log(' sudo systemctl daemon-reload && sudo systemctl enable --now kiteserver');
|
|
240
|
+
} else {
|
|
241
|
+
console.log('[server] 生产部署建议放 /opt/kite:sudo kites init /opt/kite/server.yaml,');
|
|
242
|
+
console.log('[server] 再按 README「云端部署」做 www-data 授权与 systemd unit 安装(kites deploy 一步生成)');
|
|
243
|
+
}
|
|
244
|
+
process.exit(0);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** deploy 子命令(kites deploy [输出路径] [--config <配置文件路径>]):
|
|
248
|
+
* 读取包内 systemd 模板,把 __KITES_BIN__ / __CONFIG_FILE__ 按本机环境替换后
|
|
249
|
+
* 写出可直接安装的 unit 文件,免去手工 sed;默认输出到当前目录 kiteserver.service。
|
|
250
|
+
* 产物可随升级重复生成(覆盖写),安装命令打印在输出里 */
|
|
251
|
+
function maybeDeploy() {
|
|
252
|
+
if (process.argv[2] !== 'deploy') return;
|
|
253
|
+
const args = process.argv.slice(3);
|
|
254
|
+
let rawOut = '';
|
|
255
|
+
let rawConfig = '/opt/kite/server.yaml';
|
|
256
|
+
for (let i = 0; i < args.length; i++) {
|
|
257
|
+
if (args[i] === '--config' || args[i] === '-c') {
|
|
258
|
+
rawConfig = String(args[++i] ?? '');
|
|
259
|
+
} else if (args[i].startsWith('--config=')) {
|
|
260
|
+
rawConfig = args[i].slice('--config='.length);
|
|
261
|
+
} else if (!args[i].startsWith('-') && !rawOut) {
|
|
262
|
+
rawOut = args[i];
|
|
263
|
+
} else {
|
|
264
|
+
console.error(`[server] deploy 用法:kites deploy [unit 输出路径] [--config <配置文件路径>]`);
|
|
265
|
+
console.error(`[server] 无法识别的参数:"${args[i]}"`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!rawConfig.trim() || rawConfig.startsWith('-')) {
|
|
270
|
+
console.error('[server] deploy 的 --config 后需要给出配置文件路径');
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
const configFile = path.resolve(expandHome(rawConfig));
|
|
274
|
+
const outPath = path.resolve(expandHome(rawOut || 'kiteserver.service'));
|
|
275
|
+
|
|
276
|
+
const kitesBin = resolveKitesBin();
|
|
277
|
+
if (!kitesBin) {
|
|
278
|
+
console.error('[server] PATH 里找不到 kites 可执行文件(which kites 失败),无法填写 unit 的 ExecStart');
|
|
279
|
+
console.error('[server] 请在 npm 全局安装本包的机器上运行:npm i -g @jacksontian/kite-server');
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
let tpl;
|
|
283
|
+
try {
|
|
284
|
+
tpl = fs.readFileSync(path.join(__dirname, 'deploy', 'kiteserver.service'), 'utf8');
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error(`[server] 无法读取包内 unit 模板 deploy/kiteserver.service:${err.message}`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
const content = tpl.replaceAll('__KITES_BIN__', kitesBin).replaceAll('__CONFIG_FILE__', configFile);
|
|
290
|
+
if (/__[A-Z_]+__/.test(content)) {
|
|
291
|
+
console.error('[server] 包内 unit 模板结构与预期不符(仍有未替换的占位符),请改用手工方式安装');
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
296
|
+
fs.writeFileSync(outPath, content, { mode: 0o644 });
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error(`[server] 无法写入 ${outPath}:${err.message}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
console.log(`[server] 已生成 systemd unit:${outPath}`);
|
|
302
|
+
console.log(` ExecStart: ${kitesBin} start --config ${configFile}`);
|
|
303
|
+
if (!fs.existsSync(configFile)) {
|
|
304
|
+
console.warn(`[server] 注意:${configFile} 尚不存在,安装 unit 前请先生成:kites init ${configFile}`);
|
|
305
|
+
}
|
|
306
|
+
console.log('[server] 安装(unit 以 www-data 运行,注意先完成 README「云端部署」的目录授权):');
|
|
307
|
+
console.log(` sudo install -m 644 ${outPath} /etc/systemd/system/kiteserver.service`);
|
|
308
|
+
console.log(' sudo systemctl daemon-reload && sudo systemctl enable --now kiteserver');
|
|
309
|
+
process.exit(0);
|
|
310
|
+
}
|
|
311
|
+
|
|
57
312
|
/** 可选字符串:缺省/给 null 都按空串处理,统一 trim
|
|
58
313
|
* (zod v4 中 union(z.undefined()) 不能让对象键变可选,须用 .optional()) */
|
|
59
314
|
const optStr = (errMsg) =>
|
|
@@ -94,6 +349,19 @@ const CONFIG_SCHEMA = z.strictObject({
|
|
|
94
349
|
})
|
|
95
350
|
.trim()
|
|
96
351
|
.min(1, `缺少 apiSecret${SECRET_HINT}`),
|
|
352
|
+
// Worker 专用密钥(可选):配置后 Worker 侧 API(claim/heartbeat/logs/finish)
|
|
353
|
+
// 与 /worker/ws 只认该密钥,控制台密钥(apiSecret)泄露不再等于 Worker 执行权。
|
|
354
|
+
// 留空则两端共用 apiSecret(向后兼容)
|
|
355
|
+
workerSecret: optStr('workerSecret 必须是字符串'),
|
|
356
|
+
// 多 Worker 独立密钥(可选):workerId -> 密钥映射,每台 Worker 一密钥。
|
|
357
|
+
// 配置后 Worker 侧验签命中该映射的密钥即绑定其 workerId 身份(请求自报的
|
|
358
|
+
// workerId 必须一致),Worker 之间无法互相冒充;与 workerSecret 可并存
|
|
359
|
+
// (命中共享密钥时身份仍自报,兼容旧部署)。均未配置时回落共用 apiSecret
|
|
360
|
+
workerSecrets: z
|
|
361
|
+
.record(z.string(), z.string({ error: 'workerSecrets 的密钥必须是字符串' }).trim(), {
|
|
362
|
+
error: 'workerSecrets 必须是「workerId: 密钥」的映射',
|
|
363
|
+
})
|
|
364
|
+
.optional(),
|
|
97
365
|
port: z.coerce
|
|
98
366
|
.number({ error: 'port 必须是数字' })
|
|
99
367
|
.int('port 必须是整数')
|
|
@@ -134,6 +402,11 @@ const CONFIG_SCHEMA = z.strictObject({
|
|
|
134
402
|
/** 加载并校验配置,返回归一化后的常量对象 */
|
|
135
403
|
export async function loadServerConfig() {
|
|
136
404
|
const tag = 'server';
|
|
405
|
+
maybeMeta();
|
|
406
|
+
dispatch(); // 裸调用/旧写法/未知子命令在此拦截;能走到下面的必是五个子命令之一
|
|
407
|
+
maybeKeygen();
|
|
408
|
+
maybeInit();
|
|
409
|
+
maybeDeploy();
|
|
137
410
|
const configFile = parseArgs();
|
|
138
411
|
|
|
139
412
|
let raw;
|
|
@@ -163,6 +436,46 @@ export async function loadServerConfig() {
|
|
|
163
436
|
const v = parsed.data;
|
|
164
437
|
const errors = [];
|
|
165
438
|
|
|
439
|
+
// 模板占位符原样未改:此时启动等于用公开"密钥"裸奔,显式拦下并指路 keygen
|
|
440
|
+
if (v.apiSecret === PLACEHOLDER_SECRET) {
|
|
441
|
+
errors.push('apiSecret 仍是配置模板的占位符文本,请生成真实密钥后填入:kites keygen');
|
|
442
|
+
}
|
|
443
|
+
if (v.workerSecret === PLACEHOLDER_SECRET) {
|
|
444
|
+
errors.push('workerSecret 仍是配置模板的占位符文本,请生成真实密钥后填入(或留空共用 apiSecret):kites keygen');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// workerSecrets:键即 workerId(与 worker.yaml 的 workerId 同规则);
|
|
448
|
+
// 密钥非空/非占位符,且两两不同(同密钥会让服务端无法区分身份)
|
|
449
|
+
const workerSecrets = {};
|
|
450
|
+
if (v.workerSecrets) {
|
|
451
|
+
const seen = new Map(); // secret -> workerId
|
|
452
|
+
for (const [id, secret] of Object.entries(v.workerSecrets)) {
|
|
453
|
+
if (!/^[\w.-]{1,128}$/.test(id)) {
|
|
454
|
+
errors.push(`workerSecrets 的 Worker 标识 "${id}" 非法(只能含字母/数字/._-,不超过 128 字符)`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!secret) {
|
|
458
|
+
errors.push(`workerSecrets.${id} 密钥不能为空(kites keygen 生成)`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (secret === PLACEHOLDER_SECRET) {
|
|
462
|
+
errors.push(`workerSecrets.${id} 仍是配置模板的占位符文本,请填入真实密钥:kites keygen`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (seen.has(secret)) {
|
|
466
|
+
errors.push(`workerSecrets 中 "${id}" 与 "${seen.get(secret)}" 配置了相同密钥,服务端无法区分两者身份`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
seen.set(secret, id);
|
|
470
|
+
workerSecrets[id] = secret;
|
|
471
|
+
}
|
|
472
|
+
if (v.workerSecret && seen.has(v.workerSecret)) {
|
|
473
|
+
errors.push(
|
|
474
|
+
`workerSecret 与 workerSecrets.${seen.get(v.workerSecret)} 的密钥相同,身份绑定会有歧义,请配置不同密钥`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
166
479
|
// 未配置时默认主目录下的固定位置,与配置文件位置、启动目录都解耦;
|
|
167
480
|
// 相对路径相对启动工作目录解析(path.resolve 默认行为),建议写绝对路径
|
|
168
481
|
const dataDir = path.resolve(
|
|
@@ -189,9 +502,31 @@ export async function loadServerConfig() {
|
|
|
189
502
|
console.warn(`[${tag}] 已配置 imSecret 但 imWebhook 为空,加签密钥将被忽略`);
|
|
190
503
|
}
|
|
191
504
|
|
|
505
|
+
// check 子命令(kites check --config x):与启动完全相同的校验已通过,
|
|
506
|
+
// 打印归一化摘要后直接退出,改完配置不用真启动服务(占端口)就能验证。
|
|
507
|
+
// 密钥只报长度不回显内容
|
|
508
|
+
if (isCheckMode()) {
|
|
509
|
+
console.log(`[${tag}] 配置校验通过:${configFile}`);
|
|
510
|
+
console.log(` port: ${v.port}`);
|
|
511
|
+
console.log(` dataDir: ${dataDir}`);
|
|
512
|
+
console.log(` apiSecret: 已配置(${v.apiSecret.length} 字符)`);
|
|
513
|
+
console.log(` workerSecret: ${v.workerSecret ? '已单独配置(Worker 专用密钥)' : '未配置,与 apiSecret 共用'}`);
|
|
514
|
+
console.log(` IM 通知: ${v.imWebhook ? `已配置(事件:${v.imNotifyEvents.join(', ')})` : '未配置'}`);
|
|
515
|
+
console.log(`[${tag}] 可以启动服务:kites start --config ${configFile}`);
|
|
516
|
+
process.exit(0);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Worker 侧可接受的密钥及身份绑定:workerSecrets 条目验签命中即绑定 workerId;
|
|
520
|
+
// workerSecret 为共享兼容路径(workerId 为 null 表示身份自报);
|
|
521
|
+
// 两者都未配置时回落共用 apiSecret(单密钥模式)
|
|
522
|
+
const workerAuth = Object.entries(workerSecrets).map(([workerId, secret]) => ({ secret, workerId }));
|
|
523
|
+
if (v.workerSecret) workerAuth.push({ secret: v.workerSecret, workerId: null });
|
|
524
|
+
if (!workerAuth.length) workerAuth.push({ secret: v.apiSecret, workerId: null });
|
|
525
|
+
|
|
192
526
|
return {
|
|
193
527
|
configFile,
|
|
194
528
|
apiSecret: v.apiSecret,
|
|
529
|
+
workerAuth,
|
|
195
530
|
port: v.port,
|
|
196
531
|
dataDir,
|
|
197
532
|
maxTasks: v.maxTasks,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 云服务器(systemd)上托管云端 API 的 unit 文件。
|
|
2
|
+
# 模板随 npm 包分发(包内 deploy/kiteserver.service,
|
|
3
|
+
# 路径:$(npm root -g)/@jacksontian/kite-server/deploy/kiteserver.service)。
|
|
4
|
+
# 占位符 __KITES_BIN__ / __CONFIG_FILE__ 由 `kites deploy` 按本机环境自动替换
|
|
5
|
+
# (安装步骤见 README「云端部署」一节),请勿手工编辑本模板的替换产物之外的内容。
|
|
6
|
+
|
|
7
|
+
[Unit]
|
|
8
|
+
Description=kite server
|
|
9
|
+
After=network.target
|
|
10
|
+
|
|
11
|
+
[Service]
|
|
12
|
+
Type=simple
|
|
13
|
+
User=www-data
|
|
14
|
+
# 配置文件与 data 目录的家;server.yaml 里 dataDir: ./data 相对此目录解析
|
|
15
|
+
WorkingDirectory=/opt/kite
|
|
16
|
+
# 配置只认 --config 传入的 YAML 文件(不再读环境变量),模板见包内 config.example.yaml;
|
|
17
|
+
# 启动服务必须显式走 start 子命令(裸调用只打印用法不会启动)
|
|
18
|
+
ExecStart=__KITES_BIN__ start --config __CONFIG_FILE__
|
|
19
|
+
Environment=NODE_ENV=production
|
|
20
|
+
Restart=always
|
|
21
|
+
RestartSec=5
|
|
22
|
+
|
|
23
|
+
[Install]
|
|
24
|
+
WantedBy=multi-user.target
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacksontian/kite-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "kite 云端信箱 API:HMAC 签名鉴权、任务队列、WebSocket 实时推送、手机端 Web 控制台",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
"kites": "server.js"
|
|
12
12
|
},
|
|
13
13
|
"main": "server.js",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
14
17
|
"files": [
|
|
15
18
|
"server.js",
|
|
16
19
|
"config.js",
|
|
17
20
|
"web/",
|
|
21
|
+
"deploy/",
|
|
18
22
|
"config.example.yaml"
|
|
19
23
|
],
|
|
20
24
|
"keywords": [
|