agent-loft 0.2.34 → 0.2.36
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 +10 -3
- package/package.json +7 -1
- package/src/channels/web/api.ts +27 -1
- package/src/channels/web/desktop.ts +183 -0
- package/src/channels/web/server.ts +64 -2
- package/src/channels/web/terminal.ts +154 -0
- package/src/core/config.ts +27 -0
- package/src/core/desktop.ts +281 -0
- package/src/core/terminals.ts +308 -0
- package/src/core/users.ts +16 -1
- package/web/css/remote.css +415 -0
- package/web/css/terminal.css +243 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +15 -0
- package/web/lib/ws.js +6 -1
- package/web/settings/general.js +96 -0
- package/web/settings/users.js +23 -1
- package/web/state.js +12 -1
- package/web/views/desktop-touch.js +344 -0
- package/web/views/desktop.js +539 -0
- package/web/views/layout.js +57 -1
- package/web/views/messages.js +3 -1
- package/web/views/remote.js +136 -0
- package/web/views/sidebar.js +6 -1
- package/web/views/tasks.js +2 -0
- package/web/views/terminal-touch.js +207 -0
- package/web/views/terminal.js +540 -0
- package/web/views/topbar.js +4 -3
package/README.md
CHANGED
|
@@ -65,6 +65,10 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
65
65
|
- **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
|
|
66
66
|
- **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
|
|
67
67
|
- **技能(skills)**:设置 → 技能。技能就是 Claude Code / Codex 用的那种 SKILL.md(可以带脚本和资料),由 agent-loft 按人管理,两个 agent 都能用。**来源**:在页面上写、上传技能包(`.zip` / `.skill` / 单个 SKILL.md / 整个文件夹),或者在对话里让 agent「把刚才的做法存成技能」(agent 用 `create_skill` / `update_skill`,先弹授权卡)。每人管自己的,不做分享和技能广场(以前从别人那装过的照常能用,可以卸载)。**使用**:工作模式下 agent 遇到对得上的事自己用;在输入框或定时任务的「要做的事」里打 `@` 选技能,点名这次用哪个(聊天模式下会把技能内容直接附上);飞书里发 `/skills` 看能 @ 哪些。**变量**:技能可以声明要用的环境变量(接口地址、密钥),每个人填自己的值,只注入自己的会话;密钥再写上「只允许发往」的地址就成了**受保护的密钥**——不进环境变量,agent 看不到值,只能用 `skill_request` 工具让 agent-loft 代发请求(在地址、请求头、请求体里写 `{{变量名}}`),只发往你确认过的地址、不跟随跳转、响应里出现的值会被抹掉;作者后来改了地址,要你再确认一次才放行。**机器上原有的技能**(`~/.claude/skills`、`~/.agents/skills`、插件市场和 claude.ai 同步的)只给管理员:成员的会话里看不到、调不了(Claude 用技能白名单,`/技能名` 也会被拦下;Codex 关掉它自己的技能清单),管理员在「设置 → 技能 → 本机技能」里能看到。
|
|
68
|
+
- **远程控制**:侧栏「定时任务」下面的「远程控制」,直接操作这台机器的**桌面**和**终端**,上面一键切换;切回对话时连接留在后台(侧栏入口上亮个小绿点),再进来接着用。这台机器没有图形桌面(服务器、NAS)时只有终端。
|
|
69
|
+
- 桌面:走 VNC 协议,网页里用 noVNC 显示,鼠标、键盘、剪贴板都能用,进来时不自动连、点「连接桌面」才连,可以缩放到窗口或按原始大小看、全屏(手机上也能,iPhone 上是铺满页面)、「只看不控制」、发送 Ctrl+Alt+Del 之类的组合键。手机上有两种手势(右上角切换,「?」看说明):**触控板模式**(默认)屏幕上有个指针,单指滑动移动、轻点单击、双指轻点右键、长按后拖动是按住左键拖、双指滑动滚动;**触摸模式**点哪就点哪。两种模式都能双指捏合在本地放大画面(放大后小按钮也点得准,右下角「还原」),画面下面一排 Esc / Tab / Ctrl / Alt / Win / 方向键,Ctrl、Alt、Win 点一下会粘住,带给下一个按键或点击;键盘按钮弹出软键盘打字,中文输入法也能用。Linux 上 agent-loft 在有人连时用 x11vnc 共享当前的桌面(Xorg 会话;只听 127.0.0.1、随机端口、每次随机密码,需要 `sudo apt install x11vnc`),没人连着 30 分钟后停掉;macOS 上在 设置 → 通用 → 远程控制 里改用「已有的 VNC 服务」,接系统自带的屏幕共享。
|
|
70
|
+
- 终端:新终端开在当前对话的工作目录里,可以开多个标签;`Ctrl+Shift+C` / `Ctrl+Shift+V` 复制粘贴。`` Ctrl+` `` 在对话下面拉出终端面板(高度可拖动),面板和远程控制里的是同一批终端。关掉页面、手机切到后台、断线都不结束 shell,重新打开时接着用、补上错过的输出,别的设备上也能接上;没有页面连着超过 30 分钟(设置里可改)才结束,点标签上的 × 立即结束。依赖可选的原生模块 `node-pty`:Linux 上安装时要现场编译(需要 `make`、`g++`、`python3`),没装好时终端用不了,其他功能照常。手机上单指上下滑翻看历史(在 less、vim 里是翻页),长按选中文字、接着拖动扩大选区,再点「复制」;终端下面多一排 Esc / Tab / Ctrl / Alt / 方向键 / Home / End / PgUp / PgDn / 粘贴。iOS 上软键盘弹出来时这排键会垫到键盘上面。
|
|
71
|
+
- 管理员能用;成员要管理员在 设置 → 用户 里单独「开通远程控制」。终端、桌面在 设置 → 通用 → 远程控制 里各有总开关。
|
|
68
72
|
- **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
|
|
69
73
|
|
|
70
74
|
## 安装
|
|
@@ -95,9 +99,9 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
|
|
|
95
99
|
|
|
96
100
|
- 同时连多台服务器,顶部标签栏切换、拖动排序;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知,程序坞图标显示未读数。
|
|
97
101
|
- 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话;双击打开主窗口。同一个账号的额度只查一次。(Windows 上点托盘图标弹出;Linux 上在托盘菜单里点「额度和待处理」。)
|
|
98
|
-
-
|
|
102
|
+
- 菜单栏图标可以换成额度条:Claude、Codex 各是一只像素小宠物,看样子就知道是谁,身子是 5 小时额度,下面一条是周额度。桌面端设置 → 额度显示 →「菜单栏显示额度」,选所有服务器合起来(取最高),或者按服务器分别选 Claude / Codex(图标上不写服务器名);都不选就只显示图标。「额度条显示」选实心表示剩余额度(默认,像电池)还是已用额度。只有 macOS 有,Windows / Linux 的托盘图标太小放不下。
|
|
99
103
|
- 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
|
|
100
|
-
- 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1`
|
|
104
|
+
- 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1` 上的它。防休眠:本机有会话在跑时不休眠;另有永不休眠、定时休眠(设休眠时段,时段外不休眠,时段内本机没有会话在跑、屏幕锁上后让 Mac 睡下去)和合盖不休眠(macOS,要管理员密码)。
|
|
101
105
|
- 设置(`⌘,`)的服务器列表里展开某台服务器,能看它的 Agent 环境和版本,有新版本时点「升级服务端」;桌面端自己的更新在同一页的「更新」里点「更新并重启」。
|
|
102
106
|
|
|
103
107
|
桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
|
|
@@ -149,6 +153,7 @@ agent-loft service install
|
|
|
149
153
|
- 从老版本升级:原来的登录密码自动变成管理员 `admin`,已登录的设备不用重新登录。
|
|
150
154
|
- 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
|
|
151
155
|
- 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 系统 → 网络访问 里关掉。
|
|
156
|
+
- 远程控制不经过授权卡:拿到终端就是拿到运行 agent-loft 的那个系统用户的 shell,拿到桌面就是坐在登录桌面的那个人面前。所以默认只给管理员,成员要管理员单独开通(开通时会再提醒一次);收回权限、停用或删除用户、在设置里关掉终端 / 桌面时,他开着的 shell 立刻结束、桌面立刻断开。终端和桌面的 WebSocket 和其他接口一样校验登录态和 Origin。shell 拿不到本服务自己的凭据(和 agent 子进程一样剔除环境变量)。共享桌面用的 x11vnc 只听本机、密码每次随机生成、经文件传过去(不出现在命令行里);用「已有的 VNC 服务」时,配置里的 VNC 密码会交给开通了远程控制的人的页面。
|
|
152
157
|
- 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
|
|
153
158
|
- agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
|
|
154
159
|
- 技能存在 `data/skills/`:技能本体在 `files/<技能 id>/`,每个人装了哪些、变量的值在 `users/<用户 id>.json`(0600)。普通变量注入到会话环境里,agent 能读到;受保护的密钥 agent 看不到值,但和多用户一样不是安全边界——工作模式下 agent 能直接读这些文件。别人分享的技能会在你的会话里照着执行,装之前在技能页点开看一眼它要做什么、要哪些变量。
|
|
@@ -175,6 +180,8 @@ src/
|
|
|
175
180
|
skills.ts 技能的存储:可见性、安装、停用、变量和受保护密钥确认过的地址;zip.ts 解技能包
|
|
176
181
|
skill-runtime.ts 技能怎么交给 agent:Claude 的插件目录和成员白名单、Codex 的清单、@ 技能、机器上原有的技能
|
|
177
182
|
skills-bridge.ts 技能工具在主进程这头:建 / 改技能(弹授权卡)、skill_request 代发带密钥的请求
|
|
183
|
+
terminals.ts 网页终端:用 node-pty 起 shell、留最近的输出给重连补发、没人连着到点结束、权限收回时结束
|
|
184
|
+
desktop.ts 远程桌面:找这台机器的 X 桌面、按需起 x11vnc(或用已有的 VNC 服务)、没人连着到点停掉
|
|
178
185
|
pairing.ts uploads.ts commands.ts …
|
|
179
186
|
mcp/
|
|
180
187
|
memory-server.ts 记忆工具(remember / forget / search_past_chats)的 stdio MCP 服务,两个 agent 共用
|
|
@@ -184,7 +191,7 @@ src/
|
|
|
184
191
|
codex/ codex app-server 驱动
|
|
185
192
|
channels/
|
|
186
193
|
feishu/ 飞书:一个 FeishuBot 对应一个机器人,支持多个
|
|
187
|
-
web/ HTTP + WebSocket +
|
|
194
|
+
web/ HTTP + WebSocket + 鉴权;terminal.ts 是终端的接口和 /ws/term,desktop.ts 是桌面的接口和 /ws/desktop(转给 VNC)
|
|
188
195
|
web/ 前端(原生 JS,不需要构建)
|
|
189
196
|
desktop/ 桌面端(Electron 外壳,macOS / Windows / Linux)
|
|
190
197
|
docs/ 产品介绍和截图
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-loft",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.36",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
|
|
6
6
|
"keywords": [
|
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
"@anthropic-ai/claude-agent-sdk": "^0.3.302",
|
|
48
48
|
"@highlightjs/cdn-assets": "^11.12.0",
|
|
49
49
|
"@larksuiteoapi/node-sdk": "^1.73.0",
|
|
50
|
+
"@novnc/novnc": "^1.7.0",
|
|
51
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
52
|
+
"@xterm/xterm": "^6.0.0",
|
|
50
53
|
"dompurify": "^3.2.0",
|
|
51
54
|
"marked": "^16.0.0",
|
|
52
55
|
"qrcode": "^1.5.4",
|
|
@@ -62,5 +65,8 @@
|
|
|
62
65
|
"publishConfig": {
|
|
63
66
|
"access": "public",
|
|
64
67
|
"registry": "https://registry.npmjs.org/"
|
|
68
|
+
},
|
|
69
|
+
"optionalDependencies": {
|
|
70
|
+
"node-pty": "^1.1.0"
|
|
65
71
|
}
|
|
66
72
|
}
|
package/src/channels/web/api.ts
CHANGED
|
@@ -67,6 +67,8 @@ import { clampLimit, dirsOf, inDir, inRecent, pageOf, parseCursor } from "./conv
|
|
|
67
67
|
import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
|
|
68
68
|
import { handleTasksApi } from "./tasks-api.js";
|
|
69
69
|
import { handleSkillsApi } from "./skills-api.js";
|
|
70
|
+
import { handleTerminalApi } from "./terminal.js";
|
|
71
|
+
import { handleDesktopApi, remoteStatus } from "./desktop.js";
|
|
70
72
|
import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
|
|
71
73
|
|
|
72
74
|
export const COOKIE = "aim_session";
|
|
@@ -194,6 +196,8 @@ function publicConfig(): unknown {
|
|
|
194
196
|
...cfg,
|
|
195
197
|
web: { ...cfg.web, passwordHash: undefined },
|
|
196
198
|
feishu: { ...cfg.feishu, bots: cfg.feishu.bots.map((b) => ({ ...b, appSecret: maskSecret(b.appSecret) })) },
|
|
199
|
+
// VNC 密码最多 8 位,不能像 appSecret 那样露出后 4 位
|
|
200
|
+
remote: { ...cfg.remote, vncPassword: cfg.remote.vncPassword ? MASK : "" },
|
|
197
201
|
};
|
|
198
202
|
}
|
|
199
203
|
|
|
@@ -225,6 +229,24 @@ function applyConfig(input: Partial<AppConfig>): void {
|
|
|
225
229
|
next.web.quotaRefreshMin = Number.isFinite(q) ? Math.max(0, Math.min(1440, Math.round(q))) : 10;
|
|
226
230
|
next.web.quotaEnabled = next.web.quotaEnabled !== false;
|
|
227
231
|
}
|
|
232
|
+
if (input.remote) {
|
|
233
|
+
const r = input.remote as Partial<AppConfig["remote"]>;
|
|
234
|
+
if (typeof r.terminal === "boolean") next.remote.terminal = r.terminal;
|
|
235
|
+
if (typeof r.desktop === "boolean") next.remote.desktop = r.desktop;
|
|
236
|
+
const k = Number(r.keepAliveMin);
|
|
237
|
+
if (r.keepAliveMin !== undefined && Number.isFinite(k)) next.remote.keepAliveMin = Math.max(1, Math.min(1440, Math.round(k)));
|
|
238
|
+
if (r.desktopSource === "auto" || r.desktopSource === "vnc") next.remote.desktopSource = r.desktopSource;
|
|
239
|
+
if (typeof r.display === "string") {
|
|
240
|
+
const d = r.display.trim();
|
|
241
|
+
if (d && !/^:\d{1,3}(\.\d)?$/.test(d)) throw new HttpError(400, "X 显示要写成 :0、:1 这样,留空自动找");
|
|
242
|
+
next.remote.display = d;
|
|
243
|
+
}
|
|
244
|
+
if (typeof r.vncHost === "string" && r.vncHost.trim()) next.remote.vncHost = r.vncHost.trim();
|
|
245
|
+
const port = Number(r.vncPort);
|
|
246
|
+
if (r.vncPort !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535) next.remote.vncPort = port;
|
|
247
|
+
// 回传的是打了码的旧值:不改
|
|
248
|
+
if (typeof r.vncPassword === "string" && !r.vncPassword.startsWith(MASK)) next.remote.vncPassword = r.vncPassword;
|
|
249
|
+
}
|
|
228
250
|
if (input.commands) {
|
|
229
251
|
// 只收字符串数组,别的一律当没传
|
|
230
252
|
const names = (v: unknown) =>
|
|
@@ -538,6 +560,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
538
560
|
},
|
|
539
561
|
pending,
|
|
540
562
|
user: publicMe(user),
|
|
563
|
+
remote: remoteStatus(user),
|
|
541
564
|
serverTime: Date.now(),
|
|
542
565
|
});
|
|
543
566
|
return;
|
|
@@ -576,6 +599,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
576
599
|
// ----- 定时任务 -----
|
|
577
600
|
if (p.startsWith("/api/tasks") && (await handleTasksApi(req, res, p, user))) return;
|
|
578
601
|
if (p.startsWith("/api/skills") && (await handleSkillsApi(req, res, url, user))) return;
|
|
602
|
+
if (p.startsWith("/api/terminals") && (await handleTerminalApi(req, res, p, user))) return;
|
|
603
|
+
if ((p === "/api/remote" || p.startsWith("/api/desktop/")) && (await handleDesktopApi(req, res, p, user))) return;
|
|
579
604
|
|
|
580
605
|
// ----- 目录选择器:列工作区内的子目录 -----
|
|
581
606
|
if (p === "/api/dirs" && method === "GET") {
|
|
@@ -1097,7 +1122,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
1097
1122
|
const id = userMatch[1]!;
|
|
1098
1123
|
if (!getUser(id)) throw new HttpError(404, "用户不存在");
|
|
1099
1124
|
if (method === "PATCH") {
|
|
1100
|
-
const body = await readJsonBody<{ name?: string; role?: Role; disabled?: boolean; password?: string }>(req);
|
|
1125
|
+
const body = await readJsonBody<{ name?: string; role?: Role; disabled?: boolean; password?: string; remote?: boolean }>(req);
|
|
1101
1126
|
if (id === user.id && (body.disabled === true || (body.role !== undefined && body.role !== "admin"))) {
|
|
1102
1127
|
throw new HttpError(400, "不能停用自己,也不能取消自己的管理员");
|
|
1103
1128
|
}
|
|
@@ -1108,6 +1133,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
1108
1133
|
...(body.role === "admin" || body.role === "member" ? { role: body.role } : {}),
|
|
1109
1134
|
...(typeof body.disabled === "boolean" ? { disabled: body.disabled } : {}),
|
|
1110
1135
|
...(typeof body.password === "string" ? { password: body.password } : {}),
|
|
1136
|
+
...(typeof body.remote === "boolean" ? { remote: body.remote } : {}),
|
|
1111
1137
|
}),
|
|
1112
1138
|
);
|
|
1113
1139
|
// 管理员在用户列表里重置了自己的密码:当前这台换个新登录态,别把自己踢出去
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// 远程控制的接口:GET /api/remote 告诉页面终端、桌面各自能不能用;
|
|
2
|
+
// POST /api/desktop/session 准备好桌面(必要时起 x11vnc),把这次的 VNC 密码给页面;
|
|
3
|
+
// /ws/desktop 把页面(noVNC)的 WebSocket 原样转到 VNC 服务的 TCP 端口,和 websockify 一样。
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
6
|
+
import type { Duplex } from "node:stream";
|
|
7
|
+
import type { WebSocket, WebSocketServer } from "ws";
|
|
8
|
+
import { canUseDesktop, desktopAvailability, desktopTarget, desktopViewer } from "../../core/desktop.js";
|
|
9
|
+
import { canUseRemote, isAdmin, type User } from "../../core/users.js";
|
|
10
|
+
import { config } from "../../core/config.js";
|
|
11
|
+
import { sessionUser } from "./auth.js";
|
|
12
|
+
import { HttpError, sendJson } from "./http.js";
|
|
13
|
+
import { terminalStatus } from "./terminal.js";
|
|
14
|
+
|
|
15
|
+
/** 页面要知道的:能不能用远程控制,终端、桌面各自开着没、能不能用 */
|
|
16
|
+
export function remoteStatus(user: User) {
|
|
17
|
+
const desktop = desktopAvailability();
|
|
18
|
+
return {
|
|
19
|
+
allowed: canUseRemote(user),
|
|
20
|
+
terminal: terminalStatus(user),
|
|
21
|
+
desktop: {
|
|
22
|
+
allowed: canUseDesktop(user),
|
|
23
|
+
available: desktop.available,
|
|
24
|
+
source: desktop.source,
|
|
25
|
+
// 为什么用不了、怎么装:只给管理员看(成员只知道没有桌面)
|
|
26
|
+
...(desktop.reason && isAdmin(user) ? { reason: desktop.reason } : {}),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function handleDesktopApi(req: IncomingMessage, res: ServerResponse, p: string, user: User): Promise<boolean> {
|
|
32
|
+
const method = req.method ?? "GET";
|
|
33
|
+
if (p === "/api/remote" && method === "GET") {
|
|
34
|
+
sendJson(res, 200, remoteStatus(user));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (p === "/api/desktop/session" && method === "POST") {
|
|
38
|
+
if (!canUseDesktop(user)) throw new HttpError(403, "你没有使用远程桌面的权限,找管理员在 设置 → 用户 里开通");
|
|
39
|
+
let target;
|
|
40
|
+
try {
|
|
41
|
+
target = await desktopTarget();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new HttpError(503, err instanceof Error ? err.message : String(err));
|
|
44
|
+
}
|
|
45
|
+
// 已有的 VNC 服务(比如 macOS 屏幕共享)没开:现在就说清楚,别让页面一直「正在重连」
|
|
46
|
+
if (config().remote.desktopSource === "vnc" && !(await reachable(target.host, target.port))) {
|
|
47
|
+
throw new HttpError(503, `连不上 VNC 服务 ${target.host}:${target.port},先确认它开着(macOS:系统设置 → 通用 → 共享 → 屏幕共享)`);
|
|
48
|
+
}
|
|
49
|
+
sendJson(res, 200, { password: target.password, source: config().remote.desktopSource });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 3 秒内能不能连上这个 TCP 端口 */
|
|
56
|
+
function reachable(host: string, port: number): Promise<boolean> {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const s = net.connect(port, host);
|
|
59
|
+
const done = (ok: boolean) => {
|
|
60
|
+
s.destroy();
|
|
61
|
+
resolve(ok);
|
|
62
|
+
};
|
|
63
|
+
s.setTimeout(3000, () => done(false));
|
|
64
|
+
s.once("connect", () => done(true));
|
|
65
|
+
s.once("error", () => done(false));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------- WebSocket ↔ VNC ----------
|
|
70
|
+
|
|
71
|
+
type DesktopClient = { ws: WebSocket; tcp: net.Socket; token: string; alive: boolean };
|
|
72
|
+
const clients = new Set<DesktopClient>();
|
|
73
|
+
|
|
74
|
+
/** 同时连着桌面的页面最多这么多(每个都是一整路画面) */
|
|
75
|
+
const MAX_CLIENTS = 6;
|
|
76
|
+
/** 页面收得慢:攒到这么多就先不读 VNC 那头,等它消化掉 */
|
|
77
|
+
const HIGH_WATER = 4 * 1024 * 1024;
|
|
78
|
+
|
|
79
|
+
function drop(client: DesktopClient): void {
|
|
80
|
+
if (!clients.delete(client)) return;
|
|
81
|
+
client.tcp.destroy();
|
|
82
|
+
desktopViewer(-1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stillAllowed(client: DesktopClient): boolean {
|
|
86
|
+
const user = sessionUser(client.token);
|
|
87
|
+
if (user && canUseDesktop(user)) return true;
|
|
88
|
+
client.ws.close(user ? 4403 : 4401, user ? "远程桌面权限已被收回" : "登录已失效");
|
|
89
|
+
drop(client);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function reject(socket: Duplex, status: string): void {
|
|
94
|
+
socket.write(`HTTP/1.1 ${status}\r\n\r\n`);
|
|
95
|
+
socket.destroy();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** /ws/desktop 的握手:user 已经由调用方核对过登录和 Origin */
|
|
99
|
+
export function upgradeDesktop(wss: WebSocketServer, req: IncomingMessage, socket: Duplex, head: Buffer, user: User, token: string): void {
|
|
100
|
+
if (!canUseDesktop(user)) return reject(socket, "403 Forbidden");
|
|
101
|
+
if (clients.size >= MAX_CLIENTS) return reject(socket, "429 Too Many Requests");
|
|
102
|
+
// 下面要等 x11vnc 起来(最多 10 秒):这期间浏览器断开的话,socket 上没人接 error 会把进程带崩
|
|
103
|
+
const onError = () => socket.destroy();
|
|
104
|
+
socket.on("error", onError);
|
|
105
|
+
void desktopTarget().then(
|
|
106
|
+
(target) => {
|
|
107
|
+
if (socket.destroyed) return;
|
|
108
|
+
const tcp = net.connect(target.port, target.host);
|
|
109
|
+
tcp.once("error", () => {
|
|
110
|
+
if (!socket.destroyed) reject(socket, "502 Bad Gateway");
|
|
111
|
+
});
|
|
112
|
+
tcp.once("connect", () => {
|
|
113
|
+
if (socket.destroyed) return void tcp.destroy();
|
|
114
|
+
socket.off("error", onError);
|
|
115
|
+
wss.handleUpgrade(req, socket, head, (ws) => bridge(ws, tcp, token));
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
(err) => {
|
|
119
|
+
console.error("[桌面] 准备桌面失败:", err instanceof Error ? err.message : err);
|
|
120
|
+
reject(socket, "503 Service Unavailable");
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function bridge(ws: WebSocket, tcp: net.Socket, token: string): void {
|
|
126
|
+
const client: DesktopClient = { ws, tcp, token, alive: true };
|
|
127
|
+
clients.add(client);
|
|
128
|
+
desktopViewer(1);
|
|
129
|
+
tcp.removeAllListeners("error");
|
|
130
|
+
tcp.setNoDelay(true);
|
|
131
|
+
let paused = false;
|
|
132
|
+
const resumeWhenDrained = () => {
|
|
133
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
134
|
+
if (ws.bufferedAmount < HIGH_WATER / 2) {
|
|
135
|
+
paused = false;
|
|
136
|
+
tcp.resume();
|
|
137
|
+
} else setTimeout(resumeWhenDrained, 30);
|
|
138
|
+
};
|
|
139
|
+
tcp.on("data", (chunk) => {
|
|
140
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
141
|
+
ws.send(chunk, { binary: true });
|
|
142
|
+
if (!paused && ws.bufferedAmount > HIGH_WATER) {
|
|
143
|
+
paused = true;
|
|
144
|
+
tcp.pause();
|
|
145
|
+
setTimeout(resumeWhenDrained, 30);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
tcp.on("close", () => {
|
|
149
|
+
if (ws.readyState === ws.OPEN) ws.close(4000, "桌面连接已断开");
|
|
150
|
+
drop(client);
|
|
151
|
+
});
|
|
152
|
+
tcp.on("error", () => drop(client));
|
|
153
|
+
ws.on("pong", () => (client.alive = true));
|
|
154
|
+
// 页面 → VNC:按键、鼠标都在这里,逐条复查登录态太密了;登录态 / 权限变了由 recheckDesktopClients 统一断开
|
|
155
|
+
ws.on("message", (data: Buffer) => tcp.write(data));
|
|
156
|
+
ws.on("close", () => drop(client));
|
|
157
|
+
ws.on("error", () => drop(client));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 退出登录、被踢、权限收回、设置里关了桌面:把还连着的断开 */
|
|
161
|
+
export function recheckDesktopClients(): void {
|
|
162
|
+
for (const client of [...clients]) stillAllowed(client);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function pingDesktopClients(): void {
|
|
166
|
+
for (const client of [...clients]) {
|
|
167
|
+
if (!stillAllowed(client)) continue;
|
|
168
|
+
if (!client.alive) {
|
|
169
|
+
client.ws.terminate();
|
|
170
|
+
drop(client);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
client.alive = false;
|
|
174
|
+
client.ws.ping();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function closeDesktopClients(): void {
|
|
179
|
+
for (const client of [...clients]) {
|
|
180
|
+
client.ws.close(1001, "服务重启");
|
|
181
|
+
drop(client);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -15,9 +15,10 @@ import { interactions, type Interaction } from "../../core/interactions.js";
|
|
|
15
15
|
import { getConversation } from "../../core/store.js";
|
|
16
16
|
import { taskEvents } from "../../core/tasks.js";
|
|
17
17
|
import { skillEvents } from "../../core/skills.js";
|
|
18
|
+
import { enforceTerminalAccess, killAllTerminals, terminalEvents, watchTerminalAccess } from "../../core/terminals.js";
|
|
18
19
|
import { RUNNING_VERSION } from "../../core/upgrade.js";
|
|
19
20
|
import type { Settlement } from "../../core/types.js";
|
|
20
|
-
import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
|
|
21
|
+
import { getUser, isAdmin, legacyOwnerId, userEvents, type User } from "../../core/users.js";
|
|
21
22
|
import { currentUser, handleApi, takeUploads } from "./api.js";
|
|
22
23
|
import { authEvents, ensureSetupToken, sessionUser } from "./auth.js";
|
|
23
24
|
import { canSeeKey, sessionVisibleTo } from "./access.js";
|
|
@@ -26,6 +27,9 @@ import { COOKIE } from "./api.js";
|
|
|
26
27
|
import { HttpError, sameOrigin, SECURITY_HEADERS, sendJson } from "./http.js";
|
|
27
28
|
import { hasSubscriptions, pushToAll } from "./push.js";
|
|
28
29
|
import { closeListener, listen, listening, rebind } from "./listen.js";
|
|
30
|
+
import { closeTerminalClients, pingTerminalClients, recheckTerminalClients, upgradeTerminal } from "./terminal.js";
|
|
31
|
+
import { closeDesktopClients, pingDesktopClients, recheckDesktopClients, upgradeDesktop } from "./desktop.js";
|
|
32
|
+
import { stopDesktop } from "../../core/desktop.js";
|
|
29
33
|
|
|
30
34
|
const require = createRequire(import.meta.url);
|
|
31
35
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../web");
|
|
@@ -52,6 +56,9 @@ const VENDOR: Record<string, () => string> = {
|
|
|
52
56
|
"/vendor/hljs.js": () => pkgFile("@highlightjs/cdn-assets", "es/highlight.min.js"),
|
|
53
57
|
"/vendor/hljs-light.css": () => pkgFile("@highlightjs/cdn-assets", "styles/github.min.css"),
|
|
54
58
|
"/vendor/hljs-dark.css": () => pkgFile("@highlightjs/cdn-assets", "styles/github-dark.min.css"),
|
|
59
|
+
"/vendor/xterm.js": () => pkgFile("@xterm/xterm", "lib/xterm.mjs"),
|
|
60
|
+
"/vendor/xterm.css": () => pkgFile("@xterm/xterm", "css/xterm.css"),
|
|
61
|
+
"/vendor/xterm-fit.js": () => pkgFile("@xterm/addon-fit", "lib/addon-fit.mjs"),
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
const MIME: Record<string, string> = {
|
|
@@ -97,7 +104,21 @@ function serveFile(req: http.IncomingMessage, res: http.ServerResponse, file: st
|
|
|
97
104
|
});
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
/** noVNC 是一堆互相 import 的 ES module:整个 core/、vendor/ 目录按原样给,只给 .js */
|
|
108
|
+
function novncFile(pathname: string): string | undefined {
|
|
109
|
+
const rel = pathname.slice("/vendor/novnc/".length);
|
|
110
|
+
if (!/^(core|vendor)\/[\w./-]+\.js$/.test(rel) || rel.split("/").includes("..")) return undefined;
|
|
111
|
+
return path.join(pkgDir("@novnc/novnc"), rel);
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {
|
|
115
|
+
if (url.pathname.startsWith("/vendor/novnc/")) {
|
|
116
|
+
const file = novncFile(url.pathname);
|
|
117
|
+
if (file) return serveFile(req, res, file);
|
|
118
|
+
res.writeHead(404, SECURITY_HEADERS);
|
|
119
|
+
res.end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
101
122
|
const vendor = VENDOR[url.pathname];
|
|
102
123
|
if (vendor) {
|
|
103
124
|
try {
|
|
@@ -189,6 +210,16 @@ function broadcastTasks(owner: string): void {
|
|
|
189
210
|
for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
|
|
190
211
|
}
|
|
191
212
|
|
|
213
|
+
/** 某人的终端开了 / 结束了:他开着的页面重新拉终端列表(别的设备上开的也能接上) */
|
|
214
|
+
function broadcastTerminals(owner: string): void {
|
|
215
|
+
for (const client of clients) if (client.user.id === owner) send(client, { t: "remote" });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 用户改了(比如远程控制权限被开通 / 收回)、远程控制的设置改了:页面重新问一下能用什么 */
|
|
219
|
+
function broadcastUsersChanged(): void {
|
|
220
|
+
for (const client of clients) send(client, { t: "remote" });
|
|
221
|
+
}
|
|
222
|
+
|
|
192
223
|
/** 技能变了:以前装过别人技能的人也受影响,通知所有人重新拉(列表很小) */
|
|
193
224
|
function broadcastSkills(): void {
|
|
194
225
|
for (const client of clients) send(client, { t: "skills" });
|
|
@@ -409,6 +440,7 @@ let server: http.Server | null = null;
|
|
|
409
440
|
let wss: WebSocketServer | null = null;
|
|
410
441
|
let heartbeat: NodeJS.Timeout | null = null;
|
|
411
442
|
let stopWatchingListen: (() => void) | null = null;
|
|
443
|
+
let stopWatchingRemote: (() => void) | null = null;
|
|
412
444
|
|
|
413
445
|
export async function startWeb(): Promise<void> {
|
|
414
446
|
const { host, port } = config().web;
|
|
@@ -449,13 +481,15 @@ export async function startWeb(): Promise<void> {
|
|
|
449
481
|
server.on("upgrade", (req, socket, head) => {
|
|
450
482
|
try {
|
|
451
483
|
const url = new URL(req.url ?? "/", "http://local");
|
|
452
|
-
const user =
|
|
484
|
+
const user = ["/ws", "/ws/term", "/ws/desktop"].includes(url.pathname) && sameOrigin(req) ? currentUser(req) : undefined;
|
|
453
485
|
if (!user) {
|
|
454
486
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
455
487
|
socket.destroy();
|
|
456
488
|
return;
|
|
457
489
|
}
|
|
458
490
|
const token = cookies(req)[COOKIE] ?? "";
|
|
491
|
+
if (url.pathname === "/ws/term") return upgradeTerminal(wss!, req, socket, head, user, token, url);
|
|
492
|
+
if (url.pathname === "/ws/desktop") return upgradeDesktop(wss!, req, socket, head, user, token);
|
|
459
493
|
wss!.handleUpgrade(req, socket, head, (ws) => {
|
|
460
494
|
const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null, seen: Date.now() };
|
|
461
495
|
clients.add(client);
|
|
@@ -490,6 +524,9 @@ export async function startWeb(): Promise<void> {
|
|
|
490
524
|
client.alive = false;
|
|
491
525
|
client.ws.ping();
|
|
492
526
|
}
|
|
527
|
+
enforceTerminalAccess();
|
|
528
|
+
pingTerminalClients();
|
|
529
|
+
pingDesktopClients();
|
|
493
530
|
}, 30_000);
|
|
494
531
|
heartbeat.unref();
|
|
495
532
|
|
|
@@ -501,6 +538,21 @@ export async function startWeb(): Promise<void> {
|
|
|
501
538
|
interactions.on("settled", broadcastInteraction);
|
|
502
539
|
taskEvents.on("changed", broadcastTasks);
|
|
503
540
|
skillEvents.on("changed", broadcastSkills);
|
|
541
|
+
terminalEvents.on("changed", broadcastTerminals);
|
|
542
|
+
userEvents.on("changed", broadcastUsersChanged);
|
|
543
|
+
watchTerminalAccess();
|
|
544
|
+
// 设置里关了远程桌面 / 换了桌面来源:连着的断开,自己起的 x11vnc 停掉(下次连时按新设置起)
|
|
545
|
+
stopWatchingRemote = onConfigChange((next, prev) => {
|
|
546
|
+
if (JSON.stringify(next.remote) === JSON.stringify(prev.remote)) return;
|
|
547
|
+
const r = next.remote;
|
|
548
|
+
const p = prev.remote;
|
|
549
|
+
if (!r.desktop || r.desktopSource !== p.desktopSource || r.display !== p.display) {
|
|
550
|
+
closeDesktopClients();
|
|
551
|
+
stopDesktop();
|
|
552
|
+
}
|
|
553
|
+
recheckDesktopClients();
|
|
554
|
+
broadcastUsersChanged();
|
|
555
|
+
});
|
|
504
556
|
|
|
505
557
|
await listen(server, host, port);
|
|
506
558
|
console.log(`[web] 已监听 http://${host}:${port}`);
|
|
@@ -520,6 +572,8 @@ export async function startWeb(): Promise<void> {
|
|
|
520
572
|
|
|
521
573
|
function onRevoked(): void {
|
|
522
574
|
for (const client of [...clients]) checkClient(client);
|
|
575
|
+
recheckTerminalClients();
|
|
576
|
+
recheckDesktopClients();
|
|
523
577
|
}
|
|
524
578
|
|
|
525
579
|
export async function stopWeb(): Promise<void> {
|
|
@@ -532,6 +586,14 @@ export async function stopWeb(): Promise<void> {
|
|
|
532
586
|
interactions.off("settled", broadcastInteraction);
|
|
533
587
|
taskEvents.off("changed", broadcastTasks);
|
|
534
588
|
skillEvents.off("changed", broadcastSkills);
|
|
589
|
+
terminalEvents.off("changed", broadcastTerminals);
|
|
590
|
+
userEvents.off("changed", broadcastUsersChanged);
|
|
591
|
+
stopWatchingRemote?.();
|
|
592
|
+
stopWatchingRemote = null;
|
|
593
|
+
closeTerminalClients();
|
|
594
|
+
killAllTerminals();
|
|
595
|
+
closeDesktopClients();
|
|
596
|
+
stopDesktop();
|
|
535
597
|
if (heartbeat) clearInterval(heartbeat);
|
|
536
598
|
for (const client of clients) client.ws.close(1001, "服务重启");
|
|
537
599
|
clients.clear();
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// 网页终端的接口:REST(/api/terminals)开、列、关终端;WebSocket(/ws/term?id=…&since=…)收发。
|
|
2
|
+
// 一个终端一条 WebSocket,和会话的 /ws 分开:终端的输出量大,别挤占会话事件。shell 的管理见 core/terminals.ts。
|
|
3
|
+
//
|
|
4
|
+
// 页面 → 服务端:{ t: "i", d } 输入,{ t: "r", c, r } 改大小,{ t: "ping" }
|
|
5
|
+
// 服务端 → 页面:{ t: "a", reset, d, total } 连上时补发,{ t: "o", d } 输出,{ t: "x", code, reason } 结束了
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
7
|
+
import type { Duplex } from "node:stream";
|
|
8
|
+
import type { WebSocket, WebSocketServer } from "ws";
|
|
9
|
+
import { config } from "../../core/config.js";
|
|
10
|
+
import {
|
|
11
|
+
attachTerminal,
|
|
12
|
+
canUseTerminal,
|
|
13
|
+
createTerminal,
|
|
14
|
+
detachTerminal,
|
|
15
|
+
getTerminal,
|
|
16
|
+
killTerminal,
|
|
17
|
+
listTerminals,
|
|
18
|
+
resizeTerminal,
|
|
19
|
+
terminalAvailability,
|
|
20
|
+
writeTerminal,
|
|
21
|
+
type TermPeer,
|
|
22
|
+
} from "../../core/terminals.js";
|
|
23
|
+
import type { User } from "../../core/users.js";
|
|
24
|
+
import { sessionUser } from "./auth.js";
|
|
25
|
+
import { HttpError, readJsonBody, sendJson } from "./http.js";
|
|
26
|
+
|
|
27
|
+
/** 页面要知道的:能不能用、为什么不能用、空闲多久结束 */
|
|
28
|
+
export function terminalStatus(user: User) {
|
|
29
|
+
const { available, reason } = terminalAvailability();
|
|
30
|
+
return {
|
|
31
|
+
enabled: config().remote.terminal,
|
|
32
|
+
allowed: canUseTerminal(user),
|
|
33
|
+
available,
|
|
34
|
+
...(reason && canUseTerminal(user) ? { reason } : {}),
|
|
35
|
+
keepAliveMin: config().remote.keepAliveMin,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleTerminalApi(req: IncomingMessage, res: ServerResponse, p: string, user: User): Promise<boolean> {
|
|
40
|
+
const method = req.method ?? "GET";
|
|
41
|
+
if (p === "/api/terminals" && method === "GET") {
|
|
42
|
+
sendJson(res, 200, { ...terminalStatus(user), terminals: canUseTerminal(user) ? listTerminals(user.id) : [] });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (!canUseTerminal(user)) throw new HttpError(403, "你没有使用终端的权限,找管理员在 设置 → 用户 里开通");
|
|
46
|
+
if (p === "/api/terminals" && method === "POST") {
|
|
47
|
+
const body = await readJsonBody<{ cwd?: unknown; cols?: unknown; rows?: unknown }>(req);
|
|
48
|
+
try {
|
|
49
|
+
sendJson(res, 200, createTerminal(user, body));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
const m = /^\/api\/terminals\/([A-Za-z0-9_]+)$/.exec(p);
|
|
56
|
+
if (m && method === "DELETE") {
|
|
57
|
+
if (!getTerminal(m[1]!, user.id)) throw new HttpError(404, "终端不存在(可能已经结束了)");
|
|
58
|
+
killTerminal(m[1]!);
|
|
59
|
+
sendJson(res, 200, { ok: true });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------- WebSocket ----------
|
|
66
|
+
|
|
67
|
+
type TermClient = { ws: WebSocket; peer: TermPeer; id: string; token: string; alive: boolean };
|
|
68
|
+
const clients = new Set<TermClient>();
|
|
69
|
+
|
|
70
|
+
/** 页面收得太慢(网太差):攒着的超过这么多就断开它,重连时从输出记录里补 */
|
|
71
|
+
const MAX_BUFFERED = 8 * 1024 * 1024;
|
|
72
|
+
|
|
73
|
+
function drop(client: TermClient): void {
|
|
74
|
+
if (!clients.delete(client)) return;
|
|
75
|
+
detachTerminal(client.id, client.peer);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 登录态还有效、并且还能用终端;否则断开(4401 让页面回登录页) */
|
|
79
|
+
function stillAllowed(client: TermClient): boolean {
|
|
80
|
+
const user = sessionUser(client.token);
|
|
81
|
+
if (user && canUseTerminal(user)) return true;
|
|
82
|
+
client.ws.close(user ? 4403 : 4401, user ? "终端权限已被收回" : "登录已失效");
|
|
83
|
+
drop(client);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** /ws/term 的握手:user 已经由调用方核对过登录和 Origin */
|
|
88
|
+
export function upgradeTerminal(wss: WebSocketServer, req: IncomingMessage, socket: Duplex, head: Buffer, user: User, token: string, url: URL): void {
|
|
89
|
+
const id = url.searchParams.get("id") ?? "";
|
|
90
|
+
if (!canUseTerminal(user) || !getTerminal(id, user.id)) {
|
|
91
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
92
|
+
socket.destroy();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const since = Number(url.searchParams.get("since") ?? "-1");
|
|
96
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
97
|
+
const peer: TermPeer = {
|
|
98
|
+
send: (msg) => {
|
|
99
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
100
|
+
if (ws.bufferedAmount > MAX_BUFFERED) return ws.terminate();
|
|
101
|
+
ws.send(JSON.stringify(msg));
|
|
102
|
+
},
|
|
103
|
+
close: (code, reason) => ws.close(code, reason),
|
|
104
|
+
};
|
|
105
|
+
const client: TermClient = { ws, peer, id, token, alive: true };
|
|
106
|
+
// 握手到这里的一小会儿里终端可能刚好结束了
|
|
107
|
+
if (!attachTerminal(id, user.id, peer, since)) {
|
|
108
|
+
ws.close(4000, "终端已结束");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
clients.add(client);
|
|
112
|
+
ws.on("pong", () => (client.alive = true));
|
|
113
|
+
ws.on("message", (data) => {
|
|
114
|
+
let msg: { t?: unknown; d?: unknown; c?: unknown; r?: unknown };
|
|
115
|
+
try {
|
|
116
|
+
msg = JSON.parse(data.toString());
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (typeof msg !== "object" || msg === null || !stillAllowed(client)) return;
|
|
121
|
+
if (msg.t === "i" && typeof msg.d === "string") writeTerminal(id, msg.d);
|
|
122
|
+
else if (msg.t === "r") resizeTerminal(id, msg.c, msg.r);
|
|
123
|
+
else if (msg.t === "ping") peer.send({ t: "pong" });
|
|
124
|
+
});
|
|
125
|
+
ws.on("close", () => drop(client));
|
|
126
|
+
ws.on("error", () => drop(client));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 退出登录、被踢、权限收回:把还连着的断开 */
|
|
131
|
+
export function recheckTerminalClients(): void {
|
|
132
|
+
for (const client of [...clients]) stillAllowed(client);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 心跳:清掉死连接(服务端 30 秒一次,和 /ws 一起) */
|
|
136
|
+
export function pingTerminalClients(): void {
|
|
137
|
+
for (const client of [...clients]) {
|
|
138
|
+
if (!stillAllowed(client)) continue;
|
|
139
|
+
if (!client.alive) {
|
|
140
|
+
client.ws.terminate();
|
|
141
|
+
drop(client);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
client.alive = false;
|
|
145
|
+
client.ws.ping();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function closeTerminalClients(): void {
|
|
150
|
+
for (const client of [...clients]) {
|
|
151
|
+
client.ws.close(1001, "服务重启");
|
|
152
|
+
drop(client);
|
|
153
|
+
}
|
|
154
|
+
}
|