管理后台远程服务器与浏览器 SSH/SFTP 设计
管理后台远程服务器与浏览器 SSH/SFTP 设计
1. 目标
在 TunnelMesh 管理后台中新增两个菜单:
- 远程服务器:维护可管理的服务器列表,并从浏览器发起 SSH 交互终端与 SFTP 文件操作。
- 密钥管理:维护 SSH 认证公钥,支持直接粘贴公钥,以及一次性上传私钥到 Server 提取公钥。
整体链路为:
Admin browser
→ browser SSH/SFTP client
→ WebSSH/WebSFTP transport WebSocket
→ Server stream broker
→ existing Agent relay TCP stream
→ Agent
→ target server sshd
Server 在 SSH 会话中只转发字节流,不解析 SSH 协议,不保存 SSH 密码,不实现特权文件 API。私钥只允许在“提取公钥”接口中一次性进入 Server 内存,提取完成后立即丢弃,不写入数据库、日志或审计详情。目标 sshd 继续负责认证、授权、PTY、命令执行、SFTP 权限和审计。
2. 范围
本期包含
- 远程服务器 CRUD、逻辑删除、恢复、详情和状态展示。
- 密钥管理 CRUD、逻辑删除、恢复、详情、公钥格式校验和指纹展示。
- 从浏览器发起 SSH 会话,用户名可覆盖默认用户名。
- 密码认证每次连接时输入,只保存在浏览器内存中。
- 公钥认证使用已保存的公钥元数据;浏览器可支持用户提供一次性的本地私钥。
- 浏览器本地 SSH/SFTP 客户端,终端使用 xterm.js(官方包
@xterm/xterm)。 - SFTP 浏览、上传、下载、删除、重命名和目录切换,权限完全由目标
sshd控制。 - 一次性 WebSocket ticket、会话生命周期、并发限制和审计。
- OpenAPI、用户手册、管理后台文档和 Schema 升级文档。
明确不包含
- Server 或 Agent 端 SSH 协议终止。
- Agent 原生 PTY、shell、任意命令执行 API。
- 持久化 SSH 密码、SSH 私钥、终端输出、SFTP 文件内容或文件暂存。
- Server 端单独的 SFTP 文件 API。
- 密钥的私钥托管、轮换和代用户签名服务。
- 对目标服务器的主动安全扫描。
3. 已选方案
浏览器本地 SSH 客户端
浏览器负责 SSH 协议和认证,Server 只提供经过授权的 WebSocket 字节流。这个方案的安全边界最清晰:
- Server 不接触 SSH 私钥、密码或会话密钥。
- 即使 Server 被攻破,攻击者也只能看到加密的 SSH 字节流,不能直接获得认证材料。
- SSH 算法、host key 校验、认证结果和 SFTP 授权都由目标
sshd最终决定。
前端实现必须是浏览器可运行包:SSH/SFTP 协议客户端、@xterm/xterm 与 @xterm/addon-fit 终端组件。浏览器 WebSocket 是消息流,不是原始 TCP,因此必须实现一个双工二进制流适配层,并为 SSH 客户端提供兼容 Node stream.Duplex 的适配 socket。
依赖引入前必须先完成 Vite 生产构建和浏览器冒烟验证。若候选 SSH/SFTP 包需要模拟 net、dns、文件系统或其它不安全的 Node polyfill 才能运行,视为方案不满足 Scheme A,应停止实现并重新选型,而不是把风险带入后台。
私钥提取
用户可以把私钥一次性上传到 Server 提取公钥。该请求必须使用管理后台 HTTPS 连接,私钥和可选 passphrase 只存在于请求处理内存中;解析成功或失败后立即释放,不持久化、不记录、不下发给 Agent。响应返回规范化公钥和指纹,前端立即清空私钥和 passphrase 输入框。最终保存凭据时仍只提交公钥。
提取接口:
POST /api/v1/credentials/extract-ssh-public-key
请求字段为 privateKey 和可选 passphrase,私钥正文限制为 64 KiB。解析使用 Go golang.org/x/crypto/ssh.ParsePrivateKey 与 ParsePrivateKeyWithPassphrase,响应必须设置 Cache-Control: no-store。
默认密码
远程服务器配置只保存 default_username,不保存默认密码。每次 SSH/SFTP 连接时由用户输入密码;密码仅存在于当前页面内存和 SSH 客户端会话中,关闭页面或断开连接后即丢弃。
4. 权限模型
- 管理员可以查看和管理所有远程服务器和密钥。
- 普通用户只能查看和管理自己拥有的资源。
- 一个远程服务器只能绑定同一 owner 的 Agent 和公钥。
- 逻辑删除后的远程服务器、Agent、密钥不能创建新 SSH 会话。
- Agent 禁用或离线时不能创建新 SSH 会话。
- Agent policy 必须允许所选协议、目标地址和端口;实际连接仍由 Agent 二次校验。
- 所有权限判断在 Server 端完成,前端只做可用性提示。
5. 数据模型
当前 Schema 版本为 11,本次升级为 12。变更必须同时维护:
migrations/ddl.sqlmigrations/incremental/v0011_to_v0012/mysql.sqlmigrations/incremental/v0011_to_v0012/sqlite.sqlmigrations/embed.gointernal/storage/db.go- 迁移测试
docs/operations/schema-upgrades.md
credentials
CREATE TABLE credentials (
id VARBINARY(255) PRIMARY KEY,
owner_user_id VARBINARY(255) NOT NULL,
name VARCHAR(191) NOT NULL,
type VARCHAR(32) NOT NULL,
public_key TEXT NOT NULL,
fingerprint VARCHAR(128) NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
deleted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(owner_user_id, name)
);
CREATE INDEX idx_credentials_owner ON credentials(owner_user_id, enabled, deleted_at, id);
约束:
type当前只允许ssh_public_key。public_key是公开材料,可以明文保存。- 不添加私钥列,不添加密码列。
- 逻辑删除使用
deleted_at。 - 唯一性只在未删除数据中有效,因此应用层必须校验同名活动记录;SQL 唯一索引保留在当前扩展语义下,升级前如遇历史脏数据必须先人工清洗。
remote_servers
CREATE TABLE remote_servers (
id VARBINARY(255) PRIMARY KEY,
owner_user_id VARBINARY(255) NOT NULL,
name VARCHAR(191) NOT NULL,
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
default_username VARCHAR(128) NOT NULL,
credential_id VARBINARY(255),
agent_id VARBINARY(255) NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
deleted_at TEXT,
last_connected_at TEXT,
last_result VARCHAR(32),
last_error_class VARCHAR(64),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(owner_user_id, name)
);
CREATE INDEX idx_remote_servers_owner ON remote_servers(owner_user_id, enabled, deleted_at, id);
CREATE INDEX idx_remote_servers_agent ON remote_servers(agent_id, deleted_at, id);
CREATE INDEX idx_remote_servers_credential ON remote_servers(credential_id, deleted_at, id);
约束:
port范围 1–65535。default_username不包含换行、控制字符,长度 1–128。host由 Agent 侧 SSRF 和目标策略再次校验。last_connected_at、last_result、last_error_class只保存摘要,不保存 SSH 错误原文。
设计取舍:逻辑删除与唯一索引在不同数据库中的组合语义不完全一致。本设计将唯一性作为应用层权威校验,并要求迁移前清洗活动重名数据;索引保留以加速查询和防止普通并发插入冲突。
webssh_sessions
CREATE TABLE webssh_sessions (
id VARBINARY(255) PRIMARY KEY,
owner_user_id VARBINARY(255) NOT NULL,
remote_server_id VARBINARY(255) NOT NULL,
agent_id VARBINARY(255) NOT NULL,
owner_node_id VARBINARY(255) NOT NULL,
ticket_hash VARBINARY(255) NOT NULL,
ticket_expires_at TEXT NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
connected_at TEXT,
closed_at TEXT,
close_reason VARCHAR(64)
);
CREATE INDEX idx_webssh_sessions_owner ON webssh_sessions(owner_user_id, status, created_at, id);
CREATE INDEX idx_webssh_sessions_server ON webssh_sessions(remote_server_id, status, created_at, id);
CREATE INDEX idx_webssh_sessions_expiry ON webssh_sessions(expires_at);
会话采用乐观生命周期:
pending:API 创建成功,ticket 尚未使用。active:ticket 已消费,WebSocket 已接入。closed:任一端关闭、超时或被用户/管理员终止。expired:ticket 或会话超时。
ticket 只保存 SHA-256 哈希,有效期默认 30 秒,只能消费一次。活动会话有效期默认 8 小时。
集群模式下 owner_node_id 记录实际接入 WebSocket 的 Server 节点。关闭活动会话时先更新数据库状态,再通过现有 mTLS Server-node relay 控制通道路由到 owner 节点关闭本地连接;owner 节点不可用时返回 503,由 sweeper 做最终超时收敛。
6. Repository 契约
新增三个接口和两个过滤器:
type CredentialFilter struct {
OwnerUserID string
Type string
Status string // active | deleted | all
Keyword string
}
type CredentialRepository interface {
Create(context.Context, Credential) error
Get(context.Context, string) (Credential, error)
Update(context.Context, Credential) error
Delete(context.Context, string, time.Time) error
Restore(context.Context, string) error
List(context.Context, CredentialFilter, string, int) (Page[Credential], error)
}
type RemoteServerFilter struct {
OwnerUserID string
AgentID string
Status string
Keyword string
}
type RemoteServerRepository interface {
Create(context.Context, RemoteServer) error
Get(context.Context, string) (RemoteServer, error)
Update(context.Context, RemoteServer) error
Delete(context.Context, string, time.Time) error
Restore(context.Context, string) error
List(context.Context, RemoteServerFilter, string, int) (Page[RemoteServer], error)
UpdateConnectionResult(context.Context, string, string, string, time.Time) error
}
WebSSHSessionRepository 提供创建、读取、原子消费 ticket、关闭、统计/列出活动会话和清理过期会话。原子消费必须在存储层使用条件更新,避免并发重复使用。创建会话时必须写入当前 Server 节点 owner_node_id,空值直接拒绝,保证集群关闭路由不歧义。
所有列表查询必须在 Repository 的过滤条件内完成 owner 过滤和逻辑删除过滤,不能先全局分页再丢弃无权数据。
7. API 设计
所有 API 前缀为 /api/v1,统一响应 { code, msg, data },列表使用 cursor。创建和替换请求使用 Idempotency-Key;PATCH、逻辑删除、恢复和关闭保持现有管理 API 约定。
Credentials
GET /api/v1/credentials
POST /api/v1/credentials
GET /api/v1/credentials/{id}
PUT /api/v1/credentials/{id}
PATCH /api/v1/credentials/{id}
DELETE /api/v1/credentials/{id}
POST /api/v1/credentials/{id}/restore
创建和替换请求字段:
{
"name": "team-ed25519",
"type": "ssh_public_key",
"publicKey": "ssh-ed25519 AAAA...",
"enabled": true
}
响应包含 id、name、type、publicKey、fingerprint、enabled、status、deletedAt、createdAt、updatedAt。指纹使用 OpenSSH 格式的 SHA-256 表示。
Remote servers
GET /api/v1/remote-servers
POST /api/v1/remote-servers
GET /api/v1/remote-servers/{id}
PUT /api/v1/remote-servers/{id}
PATCH /api/v1/remote-servers/{id}
DELETE /api/v1/remote-servers/{id}
POST /api/v1/remote-servers/{id}/restore
创建和替换请求字段:
{
"name": "prod-web-01",
"host": "10.0.0.12",
"port": 22,
"defaultUsername": "deploy",
"credentialId": "cred-...",
"agentId": "agent-...",
"enabled": true
}
列表和详情额外返回:
credentialNameagentNamestatus:enabled、disabled、deletedagentOnline: 布尔值,仅作为展示提示lastConnectedAtlastResultlastErrorClass
WebSSH sessions
POST /api/v1/remote-servers/{id}/ssh-sessions
GET /api/v1/ssh-sessions/{sessionId}
DELETE /api/v1/ssh-sessions/{sessionId}
创建请求:
{
"username": "deploy",
"credentialId": "cred-..."
}
响应:
{
"sessionId": "webssh-...",
"ticket": "wss-...",
"websocketPath": "/ws/webssh/webssh-..."
}
浏览器随后用 ticket 作为一次性查询参数打开:
GET /ws/webssh/{sessionId}?ticket=...
浏览器 WebSocket 不能设置自定义 Authorization 头,因此使用短效一次性 ticket。禁止把管理登录 token 放入 URL 或 Cookie。ticket 必须绑定用户、远程服务器和 Agent,且消费后立即失效。
8. WebSocket 传输协议
路径:/ws/webssh/{sessionId}
握手要求:
Origin必须命中 Server 配置的 allowlist。- ticket 哈希必须匹配
webssh_sessions记录。 - ticket 未过期且
status=pending。 - 会话 owner、远程服务器、Agent 必须仍有效。
- 当前用户活动会话数不超过默认值 5。
握手成功后,WebSocket 使用二进制消息承载 SSH 字节。Server 将 WebSocket 视为双工字节流,并在 WebSocket 与 Agent relay stream 之间双向复制。
协议语义:
- 浏览器发送的 binary message 是 SSH 客户端发出的上行字节。
- Server 发送给浏览器的 binary message 是从 Agent relay 读到的下行字节。
- 连接级关闭、读写错误和 context 取消必须同时关闭 WebSocket 和 relay stream。
- WebSocket 缓冲区写入失败时立即关闭,不无限排队。
- 半关闭语义由 SSH 客户端和目标
sshd处理;Server 只传播字节流关闭。 - 不为 SSH 协议增加新的应用层 frame 类型。
浏览器流适配
浏览器端定义:
export interface DuplexByteStream {
read(onChunk: (chunk: Uint8Array) => void): () => void
write(chunk: Uint8Array): Promise<void>
close(): void
onClose(handler: () => void): () => void
}
WebSocketByteStream 将 WebSocket binary message 转为 read 事件,将 write 转为 send()。它必须处理:
ArrayBuffer与Blob输入统一转换。bufferedAmount背压阈值,默认 4 MiB。close()幂等。- 重复 close 回调只触发一次。
- 打开前写入返回错误,不静默丢数据。
9. 前端设计
远程服务器
- 顶部筛选:名称/主机关键字、Agent、状态。
- 表格字段:名称、主机、端口、默认用户名、公钥、Agent、状态、在线状态、最近连接、操作。
- 操作:编辑、详情、SSH 连接、逻辑删除。
- 详情展示完整配置、最近连接摘要和 Agent 状态。
- 逻辑删除列表可通过状态筛选查看并恢复。
- SSH 连接前弹出配置确认:用户名、认证方式、密码或本地私钥。
密钥管理
- 顶部筛选:名称关键字、类型、状态。
- 表格字段:名称、类型、指纹、状态、创建时间、操作。
- 操作:编辑、详情、逻辑删除、恢复。
- 表单提供两个标签:
- 粘贴公钥:直接粘贴 OpenSSH authorized_keys 格式公钥。
- 从私钥提取:粘贴私钥,浏览器调用选定的浏览器版
sshpk.parsePrivateKey()提取公钥,成功后立即清空私钥。
- 私钥输入区显示固定提示:
私钥只在本浏览器解析,不会上传或保存。
终端页面
- 使用 xterm.js 渲染 PTY 输出。
- 支持窗口 resize,并把新尺寸通知 SSH channel。
- 支持关闭连接、重新创建会话和复制连接错误。
- 页面关闭或路由离开时主动关闭 WebSocket。
- 不记录终端输出。
SFTP 页面
- 支持目录切换、文件/目录列表、上传、下载、删除、重命名。
- 上传与下载显示进度和错误。
- 大文件使用分块读写,避免一次性读取导致内存峰值不可控。
- 下载优先使用浏览器 File System Access API;不支持时使用 Blob 下载。
- 删除和重命名必须二次确认。
- 不在 Server 暂存文件,不显示或记录文件内容。
10. 可观测性
新增审计事件:
credential.createcredential.updatecredential.deletecredential.restoreremote_server.createremote_server.updateremote_server.deleteremote_server.restorewebssh_session.createwebssh_session.connectwebssh_session.close
审计详情只包含资源 ID、Agent ID、用户 ID、结果类别、错误类别和时间,不包含密码、私钥、终端字节、文件内容或完整 SFTP 路径。
Prometheus 指标:
tunnelmesh_webssh_sessions_activetunnelmesh_webssh_tickets_created_totaltunnelmesh_webssh_ticket_reuse_totaltunnelmesh_webssh_streams_errors_totaltunnelmesh_webssh_stream_duration_secondstunnelmesh_webssh_bytes_total{direction}
日志输出使用结构化事件,并携带 trace ID。禁止输出 SSH 认证材料、会话字节和文件内容。
11. 并发、超时与稳定性
- ticket 有效期默认 30 秒,一次性消费。
- pending ticket 超时后标记
expired。 - active 会话默认 8 小时超时。
- 单用户 active 会话默认最多 5 个。
- Server 与 Agent relay 打开超时默认 10 秒。
- WebSocket 空闲超时默认 5 分钟;实现必须支持连接级 read deadline。若现有
WSConn接口无法表达该能力,则增加一个可选的SetReadDeadline(time.Time) error扩展接口,并为 x/net WebSocket 适配器实现它。SSH 协议自身的 keepalive 也会产生流量。 - 管理员或 owner 可主动关闭会话。
- 关闭操作必须幂等,重复关闭返回当前状态或
204。 - 会话清理器每 60 秒将过期 pending/active 会话标记为 expired/closed。
- 进程退出时通过 context 取消关闭所有本地 active 会话。
12. 安全要求
- 管理后台必须使用 HTTPS/WSS。
- WebSocket 握手必须校验 Origin allowlist。
- ticket 只保存哈希,不落库明文。
- 禁止在 URL 中携带管理登录 token。
- 禁止持久化 SSH 私钥、密码、终端输出和文件内容。
- Server 独立校验公钥格式和指纹。
- Agent 连接前再次执行 SSRF、回环、私网、链路本地、CIDR 和端口策略校验。
- Host key 校验必须默认启用;首次连接的 host key 变更必须显式确认。
- 所有资源读取按 owner 过滤,禁止信任客户端传入的 owner。
- API 错误使用稳定错误码,不透出目标系统原始错误细节。
- 上传和下载大小由浏览器与 SSH channel 可配置上限控制,默认单文件 1 GiB。
13. 测试策略
后端
- SQLite/MySQL Repository contract:CRUD、逻辑删除、恢复、owner 过滤、cursor、唯一名冲突。
- 迁移测试:v11 → v12、空库全量 DDL、
SchemaVersion=12、缺迁移失败。 - API 测试:认证、越权、分页、幂等、校验、逻辑删除、恢复和错误响应。
- ticket 测试:过期、重复使用、错误用户、错误服务器、Origin 拒绝。
- WebSocket broker 测试:二进制透传、半关闭、关闭传播、背压、并发限制、panic 恢复。
- Agent policy 测试:禁用 Agent、离线 Agent、禁用密钥、禁止端口、禁止地址。
前端
- API client 单元测试。
- 表单校验和私钥提取清空测试。
- WebSocket 流适配测试:binary、背压、幂等 close。
- SSH 会话状态机测试。
- SFTP 操作与权限错误展示测试。
- 路由、菜单、i18n 和组件渲染测试。
- 生产构建测试。
E2E
使用本地 SSH 测试容器:
- Agent 连接 Server。
- 管理后台创建服务器与公钥。
- 打开 WebSSH 并完成密码认证。
- 执行简单命令并验证终端输出。
- 打开 SFTP,上传、下载、删除一个小文件。
- 断开 Agent,验证会话关闭和错误提示。
14. 文档
必须同步更新:
docs/api/openapi.yamldocs/README.mddocs/user-guide/server-admin.mddocs/operations/schema-upgrades.mddocs/deployment/nginx.md,补充/ws/webssh/的 WSS 代理配置docs/pull-requests/2026-09-12-admin-webssh-sftp.md