WebSSH lrzsz(ZMODEM) 支持与凭据自动认证 实施计划
WebSSH lrzsz(ZMODEM) 支持与凭据自动认证 实施计划
日期:2026-09-12。状态:已实现(用户于 2026-09-12 确认 T1–T8 全量执行)。
实现与本计划一致,另有两处计划外但必要的补充:一是修复 internal/agent 中既有的时序脆弱测试
TestConnectionControllerRequiresServerAckAndRestartsFailedConnection(固定 20ms sleep 与 supervise
的 20ms 退避对撞,约 2/3 概率失败),改为按截止时间轮询重启计数,使 go test ./... 门禁可复现;
二是 E2E harness 的 sshhost 升级为真实 pty shell(creack/pty + /bin/zsh,PATH 含真实 sz/rz),
以便用真实 lrzsz 二进制验证收发字节一致。
1. 目标
两个用户需求,均落在 WebSSH/SFTP 链路:
- 终端支持 lrzsz:远端执行
sz/rz时,浏览器侧完成 ZMODEM 收发(sz→ 浏览器下载文件;rz→ 浏览器选择本地文件上传到远端),传输过程有可见进度与取消入口,传输期间终端输入不被当作 shell 输入发送。 - 凭据类型扩展 + 自动认证:密钥管理新增
password类型;ssh_public_key类型可选保存私钥(加密)。远程服务器绑定凭据后,进入 WebSSH 终端或 SFTP 页面时直接用凭据完成认证,不再弹认证窗口;仅当主机未配置凭据、或凭据不含可用秘密(例如只存了公钥)时,才回退到现有的手动输入密码窗口。
非目标:不实现服务器端 SSH 认证(认证始终在浏览器内 libssh2 完成,Server 只转发加密字节);不做 ZMODEM 协议自研(使用 zmodem.js);不做凭据共享/授权给其他用户(凭据仍仅所有者可用)。
2. 架构决策
2.1 ZMODEM:浏览器内协议栈 + 字节级拦截
- 依赖
zmodem.js@0.1.10(纯 JS、零运行时依赖、BSD 风格许可)。其Sentry正是为”终端字节流”设计:consume(remoteBytes)把非 ZMODEM 字节交给to_terminal,把协议字节留给内部状态机,回包经sender回调发出。这与本项目”channel 字节 ↔ xterm”的结构一一对应,不需要自研协议。 - 新增
web/src/webssh/zmodem.ts(createZmodemBridge)作为唯一封装点:输入远端字节、输出终端字节/通道字节/UI 事件。WebSSHTerminal.vue只消费事件与调用sendFiles/abort,不直接接触zmodem.js。 - 方向语义:远端
sz(远端发送)→ Sentry 检测到 ZRQINIT → 接收会话 →offer事件 → 浏览器组装 Blob 下载;远端rz(远端接收)→ 检测到 ZRINIT → 发送会话 → UI 打开文件选择 →Zmodem.Browser.send_files(session, files)。 - 检测即确认(
detection.confirm()):终端里出现 ZMODEM 魔数只可能是用户主动运行了 rz/sz;误报可用”取消传输”止损(session.abort()发送 ZABORT)。不做弹窗确认,避免打断脚本化传输。 - 传输期间
terminal.onData的输入被丢弃(ZMODEM 对端期望协议字节而非键入),UI 显示传输面板;session_end/abort 后恢复。 - 终端字节管道改为保留
Uint8Array:channel.read的原始字节先交给 bridge,bridge 的to_terminal再用流式TextDecoder(decode(chunk, {stream:true}))写入 xterm,顺带修复多字节字符被 chunk 边界切断的隐患。 - 下载复用 SFTP 的 Blob+anchor 模式,抽公共helper
web/src/webssh/download.ts(downloadBlob(name, blob)),SFTP 与 ZMODEM 共用,满足 DRY。
2.2 凭据秘密:复用现有 AES-GCM SecretStore,一次一密文交付浏览器
- 秘密(密码 / 私钥 / 私钥口令)以单个加密 JSON blob 入库:
{"password":...}或{"privateKey":...,"passphrase":...},使用现有internal/auth.SecretStore(AES-256-GCM,key id + version,密钥来自TUNNELMESH_TOKEN_ENCRYPTION_KEY)。选 blob 而非 9 列明文字段:一次加解密路径、一次迁移、后续加类型不改表。 - 列表/详情 API 只返回
hasSecret布尔,绝不返回密文或明文;明文只在POST /api/v1/remote-servers/{id}/ssh-sessions的创建响应中出现一次(auth字段),响应头Cache-Control: no-store,并写审计credential.revealed。浏览器沿用现有内存态(PendingWebSSHSession),关闭即丢弃。 - 未配置
TUNNELMESH_TOKEN_ENCRYPTION_KEY时 SecretStore 为 nil:创建带秘密的凭据直接报错(快速失败),自动认证不可用并回退手动窗口;不允许静默降级为明文存储。 - 所有权不变:远程服务器使用凭据前已有
credential.OwnerUserID == actor校验,秘密只交付给凭据所有者本人。 ssh_public_key且未存私钥的凭据:无法自动认证,UI 回退手动窗口(保留”选择公钥 + 输入密码”现状)。
2.3 Schema:v12 → v13,只加可空列
credentials新增 4 个可空列:secret_ciphertext TEXT、secret_nonce TEXT、secret_key_id VARCHAR(64)、secret_version INTEGER。- 密码类型凭据的
public_key/fingerprint存空字符串(两列保持 NOT NULL),避免 SQLite 无法 MODIFY 列的问题;MySQL 与 SQLite 增量脚本因此完全同构(仅 ADD COLUMN)。 - 同步更新:
migrations/ddl.sql、migrations/incremental/v0012_to_v0013/{mysql.sql,sqlite.sql}、internal/storage/db.go的SchemaVersion = 13、迁移测试、docs/operations/schema-upgrades.md。
3. 技术栈与规格引用
- Go:现有分层 Handler → Service → Repository;秘密加解密只在 Service(
CredentialService/WebSSHSessionService),Repository 只存 base64 字符串。 - 前端:Vue 3 + Element Plus;新模块
web/src/webssh/zmodem.ts、web/src/webssh/download.ts;视图改动限WebSSHTerminal.vue、Credentials.vue、RemoteServers.vue。 - 依赖:
web/package.json增加zmodem.js: 0.1.10(精确版本)。 - API 规格:
docs/api/openapi.yaml同步 credentials 的secret/hasSecret、remote-servers 的credentialType/credentialHasSecret、ssh-sessions 响应的auth。
4. 全局约束
- 秘密不得出现在:列表/详情 API、审计详情、日志、Prometheus、traceroute、错误消息。审计只记
credentialId与动作。 - 长度上限:密码 4 KiB、私钥复用
maxSSHPrivateKeyBytes(64 KiB)、口令 1 KiB;超限在边界拒绝。 - 所有新接口行为补 OpenAPI 与用户文档;前端补 i18n 双语键(
i18n.spec.ts强制对齐)。 - TDD:每个任务先写失败测试并记录红输出,再最小实现。
5. 精确文件清单
新增:
web/src/webssh/zmodem.ts、web/src/webssh/zmodem.spec.tsweb/src/webssh/download.tsweb/src/tests/zmodem-terminal.spec.tsmigrations/incremental/v0012_to_v0013/mysql.sql、migrations/incremental/v0012_to_v0013/sqlite.sql
修改:
migrations/ddl.sql、internal/storage/db.go、internal/storage/models.go、internal/storage/credential_repository.go(+contract 测试)internal/server/credential_service.go、internal/server/credential_api.go、internal/server/credential_service_test.go、internal/server/credential_api_test.gointernal/server/webssh_session_service.go、internal/server/webssh_api.go、internal/server/webssh_session_service_test.go、internal/server/webssh_api_test.gointernal/server/remote_server_service.go、internal/server/remote_server_api.go(响应补credentialType/credentialHasSecret)+ 对应测试internal/storage/mysql_test.go、internal/storage/sqlite_test.go(迁移链测试)web/package.json、web/package-lock.jsonweb/src/api/credentials.ts、web/src/api/remote-servers.ts、web/src/api/webssh.tsweb/src/views/Credentials.vue、web/src/views/RemoteServers.vue、web/src/views/WebSSHTerminal.vueweb/src/views/WebSFTP.vue(改用共享 download helper)web/src/tests/credentials-view.spec.ts、web/src/tests/remote-servers-view.spec.ts、web/src/tests/webssh-terminal.spec.ts、web/src/tests/web-sftp-view.spec.tsweb/src/i18n/messages/zh-CN.ts、web/src/i18n/messages/en-US.tsdocs/api/openapi.yaml、docs/user-guide/server-admin.md、docs/operations/schema-upgrades.md、docs/operations/configuration.md、docs/pull-requests/2026-09-12-admin-webssh-sftp.md
6. 任务拆解(TDD)
T1 Schema v13 与 Credential 秘密字段(存储层)
接口:storage.Credential 增加 SecretCiphertext/SecretNonce/SecretKeyID string、SecretVersion int(零值=无秘密);Repository Create/Update/List/Get 往返不丢字段;CredentialFilter 不变。
红:credential_repository_test.go 新增”秘密字段往返”用例,先红于 unknown field SecretCiphertext;迁移测试先红于 schema_meta.version = 12, want 13。
实现:ddl.sql 加 4 列;增量脚本两方言同构 ADD COLUMN;SchemaVersion=13;models/repository 补字段与扫描。
绿:go test ./internal/storage/... -count=1。
T2 CredentialService 秘密写入与读取(服务层)
接口:CredentialInput 增加 Secret CredentialSecretInput{Password, PrivateKey, Passphrase string};Create/Patch 按类型校验(password 必须有 Password;ssh_public_key 必须有 PublicKey,PrivateKey 可选);加密为 JSON blob 后落库;GetWithSecret(ctx, actor, id) 仅所有者可解密返回明文结构;无 SecretStore 时创建带秘密凭据返回 ErrSecretStoreUnavailable。
红:先红于 undefined: CredentialSecretInput 与 s.credentials.GetWithSecret;再红于”无加密密钥时创建成功”(期望报错)。
绿:go test ./internal/server/... -run Credential -count=1。
T3 凭据 API:secret 入参、hasSecret 出参、reveal 审计
接口:POST/PATCH /api/v1/credentials 接受 secret:{password?,privateKey?,passphrase?};响应 credentialResponse 增加 HasSecret bool,永不含密文/明文;写入秘密时审计 credential.secret-set。
红:API 测试先红于 400/字段缺失与 hasSecret 断言。
绿:go test ./internal/server/... -count=1。
T4 远程服务器响应补凭据能力位
接口:remoteServerResponse 增加 CredentialType *string、CredentialHasSecret *bool(list/get 均联查凭据表;凭据已删除时为 null)。
红:remote_server_api_test.go 断言新字段先红。
绿:同上。
T5 WebSSH 会话创建交付一次性认证材料
接口:websshTicketResponse 增加 Auth *WebSSHAuthPayload{Kind string; Password, PrivateKey, Passphrase string};仅当服务器绑定凭据且凭据有秘密且所有者匹配时填充;响应写 Cache-Control: no-store;审计 credential.revealed(details 只含 credentialId)。
红:webssh_api_test.go 先红于响应无 auth;越权用例(非凭据所有者)先红于 201(期望 403/409 现有语义)。
绿:go test ./internal/server/... -count=1。
T6 前端凭据页与自动认证流程
Credentials.vue:类型选择(ssh_public_key/password);password 只显示密码输入(show-password);ssh_public_key 保留公钥 textarea,并新增”保存私钥用于自动认证(可选)”textarea + 口令输入;提交后清空秘密输入。RemoteServers.vue:openSSH/openSFTP先判断row.credentialHasSecret:是 → 直接createWebSSHSession({username: row.defaultUsername, credentialId}),用响应auth组装PendingWebSSHSession并跳转;否 → 现有认证窗口。创建失败仍显示 Server 原因。- 红:
credentials-view.spec.ts、remote-servers-view.spec.ts标记断言(credentialHasSecret、auth.kind、secret.password等)先红。 绿:npm test -- --run相关 spec。
T7 ZMODEM bridge 与终端集成
zmodem.ts:createZmodemBridge({toTerminal, toPeer, onEvent});事件:detected(role)、send-required、receive-offer{name,size}、progress{transferred,total}、completed{name}、aborted、ended、error;方法:consumeFromRemote、sendFiles(File[])、abort()、active、dispose()。WebSSHTerminal.vue:channel.read原始字节 → bridge;bridge.toTerminal → 流式解码写 xterm;bridge.toPeer →channel.write;terminal.onData在bridge.active时丢弃;传输面板(角色、文件名、进度、取消);send-required触发隐藏<input type=file multiple>;接收完成调用downloadBlob。- 红:
zmodem.spec.ts先红于createZmodemBridge is not a function;zmodem-terminal.spec.ts标记先红。 - 用例:普通字节原样到终端且不发通道;ZRINIT 帧(用
zmodem.js/src/zheader的 Header 序列化构造,保证 CRC 正确)→ 角色 send +send-required+ 字节不进终端;ZRQINIT 帧 → 角色 receive,start()后toPeer收到以**\x18B开头的 ZRINIT 回包;abort()后active=false且toPeer收到 ZABORT;session_end后终端输入恢复发送。 绿:npm test -- --run。
T8 文档、OpenAPI、i18n、PR 记录
docs/user-guide/server-admin.md:lrzsz 使用(sz/rz、传输面板、取消)、凭据类型与自动认证行为、回退条件。docs/operations/configuration.md:自动认证依赖TUNNELMESH_TOKEN_ENCRYPTION_KEY的说明。docs/operations/schema-upgrades.md:v13 升级/回滚说明(只加可空列,滚动升级安全)。docs/api/openapi.yaml与 PR 文档第九轮。
7. 验证命令
go test ./... -count=1
go test -race ./...
go vet ./...
git diff --check
cd web && npm test -- --run && npm run build
rsync -a --delete web/dist/ internal/server/web_dist/ && ./scripts/verify-web-embed.sh
浏览器端到端(复用 /tmp/tm-e2e harness,升级 sshhost 为真实 pty shell:creack/pty + /bin/zsh,PATH 含 homebrew lrzsz):
- 终端输入
sz run.mjs→ CDPPage.setDownloadBehavior指向临时目录 → 断言落盘文件与源文件字节一致(真实 ZMODEM 接收)。 - 远端
rz→ 页面出现文件选择 → CDPDOM.setFileInputFiles注入本地文件 → 断言远端 cwd 出现同字节文件(真实 ZMODEM 发送)。 - 配置 password 凭据的服务器:点”SSH 连接”不弹认证窗口直达终端;点”SFTP 文件”直达文件列表。
- 未配置凭据的服务器:仍弹认证窗口(回退路径)。
8. 回滚注意事项
- v13 只加可空列:回退应用后旧二进制忽略新列,无需反向 DDL;秘密凭据在旧版本表现为”无秘密”,自动认证自动退化为手动窗口。
- ZMODEM 纯浏览器侧:回退前端产物即消失,Server 无开关、无状态。
- 已交付过明文的会话不受回滚影响(密码只在浏览器内存)。