WebSSH 会话管理实施计划
WebSSH 会话管理实施计划
日期:2026-09-12。状态:已实现(用户于 2026-09-12 确认 T1–T7 全量执行)。
实现与本计划一致,唯一偏差是 WebSSHSessionPage.hasMore 采用与仓库既有分页类型
(CredentialPage、AgentPolicyPage)一致的可选字段写法;验证证据见
docs/pull-requests/2026-09-12-admin-webssh-sftp.md 第六轮。
目标
让使用者在管理后台看到自己当前活跃的 WebSSH 会话并主动断开,解决遇到 “活跃 SSH 会话数已达上限”后没有自救手段的问题。可选任务 T7 把远端 shell 的 退出码透出到终端关闭态,并提供“重新连接”入口。
背景与证据
- 2026-09-12 已修复配额泄漏:桥接结束时 owning 节点回写
closed并记录close_reason(remote_closed/client_disconnected/server_closed/bridge_closed)。但历史遗留行、以及浏览器在消费 ticket 后崩溃等极端路径仍可能 留下active行,用户目前只能等 8h TTL 或重启 Server。 - 现有管理 API 只有
POST /api/v1/remote-servers/{id}/ssh-sessions、GET|DELETE /api/v1/ssh-sessions/{id};前端closeWebSSHSession已存在但没有任何 视图调用。 storage.WebSSHSessionRepository.ListActiveByOwner已存在,但无分页、未暴露到 API。idle_timeout已在internal/server/webssh_broker.go的copyBrowserToAgent(浏览器侧读 deadline)生效,本计划不重复实现服务端 idle 执行。
架构决策
- 只暴露“自己的活跃会话”。跨用户会话管理需要新的授权模型与审计语义,超出本次问题 范围;遵循 YAGNI,管理员同样只列自己的会话。
- 不新增菜单与路由。在“远程服务器”页增加“活跃会话”卡片:会话的语义入口就是远程
服务器,且避免同时改动
AppShell.vue菜单断言、breadcrumbs与router.ts。 - 不改 Schema。列表读现有
webssh_sessions表,cursor 使用id,与credentialRepo.List的游标语义一致。 - 复用现有分页与响应设施:
pageArgs、decodeCursor、storage.Page[T]、pageData、queryLimit;响应统一{code,msg,data:{items,nextCursor,hasMore}}。 - 断开复用现有
DELETE /api/v1/ssh-sessions/{id}(Service.Close → broker CloseLocal → 桥接结束回写 closed),不新增关闭接口。
技术栈
Go(internal/storage、internal/server)、Vue 3 + TypeScript + Element Plus
(web/)、vitest、go test。无新依赖。
全局约束
- Handler → Service → Repository 分层;权限只在服务端判定,不信任客户端 owner。
- 列表接口 cursor 分页;统一响应包裹;OpenAPI 与用户文档同步;i18n 中英文同步。
- 严格 TDD:每个任务先写失败测试并确认失败,再最小实现,再补边界。
- 未经另行明确授权,不执行 commit / push / merge。
任务 T1:storage 分页列出某用户活跃会话
文件:internal/storage/repository.go、internal/storage/webssh_session_repository.go、
internal/storage/webssh_session_repository_test.go
接口:在 WebSSHSessionRepository 增加
ListActivePage(ctx context.Context, ownerUserID string, now time.Time, cursor string, limit int) (Page[WebSSHSession], error)。
实现镜像 credentialRepo.List:条件
owner_user_id=? AND status='active' AND expires_at>?,cursor 非空时追加 id>?,
ORDER BY id LIMIT ?(limit+1 前瞻),经 pageArgs/decodeCursor 处理参数。
TDD:
- 红:新增
TestWebSSHSessionListActivePage:写入 3 条 active + 1 条 closed; limit=2 时 items=2、hasMore=true、nextCursor 非空;用 nextCursor 取第二页 items=1、 hasMore=false;closed 行不出现在任何一页。预期失败原因:接口方法不存在,编译失败。 - 最小实现接口与 SQLite/MySQL 通用 SQL(仅使用
?占位与现有tm()时间格式化)。 - 绿:
go test ./internal/storage -run TestWebSSHSession -count=1。 - 补边界:limit=0/负数走
pageArgs默认;cursor 指向不存在的 id 时返回空页。
任务 T2:Service List 强制所有者范围
文件:internal/server/webssh_session_service.go、internal/server/webssh_session_service_test.go
接口:func (s *WebSSHSessionService) List(ctx context.Context, actor auth.Principal, cursor string, limit int) (storage.Page[storage.WebSSHSession], error);
内部固定 ownerUserID = actor.UserID,nil receiver 返回 ErrWebSSHSessionUnavailable。
TDD:
- 红:
TestWebSSHSessionServiceListIsOwnerScoped:alice、bob 各 1 条 active; alice List 只含自己的会话;admin 身份 List 也只含 admin 自己的(本次不做跨用户)。 预期失败原因:方法不存在,编译失败。 - 最小实现:直接委托 T1 的
ListActivePage。 - 绿:
go test ./internal/server -run TestWebSSHSessionService -count=1。
任务 T3:API GET /api/v1/ssh-sessions
文件:internal/server/api.go、internal/server/webssh_api.go、
internal/server/webssh_api_test.go、docs/api/openapi.yaml
路由:api.go 的 case "ssh-sessions" 在现有 len(parts)==2 分支前增加
len(parts)==1 && r.Method == http.MethodGet → a.listWebSSHSessions(w, r, p)。
Handler:queryLimit(r) 与 r.URL.Query().Get("cursor") → websshService.List →
writeJSON(w, http.StatusOK, pageData(items, page.NextCursor, page.HasMore)),
items 逐个经现有 publicWebSSHSession 映射。
OpenAPI:新增 /ssh-sessions get,参数 cursor/limit,responses 200(items 复用
WebSSHSessionEnvelope 的 data 形状,参照 /credentials 的列表写法)与 401。
TDD:
- 红:
TestWebSSHSessionAPIListOwnOnly:无 token 401;alice 只看到自己;limit=1 时 两页并集等于 alice 的全部 active;响应含nextCursor/hasMore。预期失败:404。 - 最小实现路由与 handler。
- 绿:
go test ./internal/server -run TestWebSSHSession -count=1。
任务 T4:前端 API 客户端
文件:web/src/api/webssh.ts、web/src/tests/webssh-api.spec.ts
接口:export type WebSSHSessionPage = { items: WebSSHSession[]; nextCursor?: string; hasMore: boolean };
export function listWebSSHSessions(params: { cursor?: string; limit?: number } = {}),
query 拼接 cursor/limit,返回 api<WebSSHSessionPage>('/ssh-sessions?...')。
TDD:
- 红:spec 断言
fetch以/api/v1/ssh-sessions?limit=50与带cursor=的形式被调用。 - 最小实现。
- 绿:
cd web && npx vitest run src/tests/webssh-api.spec.ts。
任务 T5:远程服务器页“活跃会话”卡片
文件:web/src/views/RemoteServers.vue、web/src/tests/remote-servers-view.spec.ts、
web/src/i18n/messages/zh-CN.ts、web/src/i18n/messages/en-US.ts
UI:在工具栏卡片之前插入 tm-card,标题 remoteServers.sessions.title;el-table
列:目标(remoteServerId 关联本页已加载服务器名称,缺失显示 id 并标记
unknownTarget)、创建时间、操作列“断开”(el-popconfirm 确认后调用现有
closeWebSSHSession(id),成功后 ElMessage 提示并刷新列表);空态
remoteServers.sessions.empty;失败态 remoteServers.sessions.loadFailed + 重试。
i18n 双语新增 remoteServers.sessions:title, empty, loadFailed, disconnect,
disconnected, disconnectFailed, target, createdAt, unknownTarget。
TDD:
- 红:view spec 断言源码包含
listWebSSHSessions、closeWebSSHSession、remoteServers.sessions.title,且 zh/en 均含sessions:与disconnect:。 - 最小实现视图与文案。
- 绿:
cd web && npx vitest run src/tests/remote-servers-view.spec.ts。
任务 T6:文档
文件:docs/user-guide/server-admin.md
在“远程服务器与浏览器 SSH/SFTP”章节追加“活跃会话”段落:卡片含义、断开语义 (等价 DELETE 单条会话)、配额立即释放、历史会话以审计日志为准。
任务 T7(可选,需与计划一并确认):终端关闭态透出退出码 + 重新连接
文件:web/src/webssh/ssh-client.ts、web/src/webssh/ssh-client.spec.ts、
web/src/views/WebSSHTerminal.vue、web/src/tests/webssh-terminal.spec.ts、
web/src/i18n/messages/zh-CN.ts、web/src/i18n/messages/en-US.ts
接口:onExit(cb: (info: { status: number | null; signal: string | null }) => void);
ssh-client 在 channel 读到 EOF/关闭后调用 @verdigris/libssh2.js@^0.1.9 已导出的
_ssh2_channel_get_exit_status(channel): number 与
_ssh2_channel_get_exit_signal(channel): string(见
web/node_modules/@verdigris/libssh2.js/dist/libssh2.d.ts:259-260)读取并透传,
不需要重建或替换 WASM 产物。
UI:closed 态在 webssh.remoteExited 之后追加 webssh.exitStatus 行(含 status 或
signal),并提供“重新连接”按钮:携带当前 sessionId 对应的 remoteServerId 回到
/remote-servers 并自动打开连接对话框。i18n 双语新增 webssh.exitStatus、
webssh.reconnect。
TDD:
- 红:ssh-client spec 断言
onExit回调收到{status: 1, signal: null};terminal spec 断言源码含webssh.reconnect与 exitStatus 渲染 marker。 - 最小实现。
- 绿:
cd web && npx vitest run src/tests/webssh-terminal.spec.ts src/webssh/ssh-client.spec.ts。
任务间接口
T1 → T2:ListActivePage 签名与 storage.Page[WebSSHSession];
T2 → T3:WebSSHSessionService.List 签名;
T3 → T4:data:{items,nextCursor,hasMore} 形状与 publicWebSSHSession 字段;
T4 → T5:listWebSSHSessions / closeWebSSHSession;
T5 依赖同页已加载的 remoteServers 列表做名称映射;
T7 独立于 T1-T6,可单独裁剪。
验证命令
go test ./... -count=1
go test -race ./...
go vet ./...
git diff --check
cd web && npm test -- --run
cd web && npm run build
rsync -a --delete web/dist/ internal/server/web_dist/
./scripts/verify-web-embed.sh
go build ./cmd/...
回滚注意事项
纯增量 API、视图与文案;无 Schema、无配置项、无迁移。回滚即回退对应提交;已建立的 会话与配额不受影响。OpenAPI、i18n 与文档必须与代码同提交回退,避免契约漂移。
风险与边界
- 列表只含
active:历史会话不可见,审计走既有webssh.session.created/closed事件。 - 名称映射依赖本页服务器列表;服务器被逻辑删除时显示 id 并标记
unknownTarget。 - T7 依赖的 exit status/signal 绑定已确认存在于当前
@verdigris/libssh2.js@^0.1.9构建中;若运行期 channel 已释放或取值异常,回调退化为null,UI 只显示通用文案。