工作中访问内网的数据库、缓存和搜索服务,我一直在跳板机后面开 SSH 本地端口转发,再用一堆 bash 脚本统一启动这些隧道。脚本越攒越多,散在各个目录,哪条规则改过、哪个隧道断了,全靠记忆和手动重跑。市面上有终端、有 SFTP 工具,但没有一个专门用来统一管理和记录 SSH 本地转发的,所以我写了 Via------一个 macOS 桌面端的 SSH 本地端口转发管理器。
读完这篇文章,能判断它适不适合自己的工作流。仓库地址:https://github.com/colinrs/via
Via 在解决什么问题
SSH 的 -L 参数能把本机端口经跳板机转发到内网地址,Via 做的就是这个,只是把它做成了一张可编辑的表格:
text
127.0.0.1:3306 → bastion.example.com(SSH 会话) → rds.internal:3306
一条规则一行,本地端口、目标主机、目标端口三个字段,加上状态和开关。本机连 127.0.0.1:3306,就等于连上只有跳板机才够得到的内网服务。规则按会话分组,一个跳板机下面挂多条转发,数据库、Redis、OpenSearch 各占一行。

转发数据链路怎么实现
核心逻辑在 src-tauri/src/services/forwarder.rs 里,整个流程只有四步:
- 在
127.0.0.1:<local_port>上 bind 一个TcpListener。 - 每 accept 到一个本地连接,通过已认证的 SSH 会话
open_direct_tcpip打开一条到target_host:target_port的通道。 - 在本端连接和 SSH 通道之间
copy_bidirectional双向拷贝字节。 - 字节流就透明地流到了内网服务。
关键代码的骨架如下,重点看 accept 循环和每个连接的处理方式:
rust
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, rule.local_port)).await?;
let task = tokio::spawn(async move {
loop {
tokio::select! {
_ = task_cancel.cancelled() => break,
accepted = listener.accept() => match accepted {
Ok((mut inbound, _)) => {
// 每个本地连接单独开一条 direct-tcpip 通道
tokio::spawn(async move {
if let Ok(mut outbound) = session.open_direct_tcpip(&host, port).await {
let _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await;
}
});
}
Err(_) => break,
}
}
}
});
这段代码里,每个进来的连接都单独 spawn 一个 task,各开各的 direct-tcpip 通道,互不影响,一条规则失败不会影响同一会话里的其他规则。bind 地址固定是 Ipv4Addr::LOCALHOST,这决定了 Via 的一条安全边界:隧道永远只监听回环,不会暴露到局域网。
bind 失败时把 AddrInUse 单独映射成 ViaError::PortConflict,前端就能直接显示「端口被占用」。
技术选型
前端是 Vue 3 + TypeScript + Vite,配置、加密、SSH、监听、状态和重连这些逻辑都在 Rust(Tauri 2)后端。SSH 客户端用 russh,本地配置存在单个 SQLite 文件里。
对比 Electron,选 Tauri 2 是因为后端要长期持有 TCP 监听、SSH 会话和重连调度,这些放 Rust 里更直接、内存占用也更低。前端只负责展示和操作,所有状态由 Rust 后端通过 Tauri 事件推过来。
代码结构里能直接看到这个分工。前端不直接调后端,中间隔着 createViaStore,它把 Tauri 的 invoke/listen 包成 ViaBridge 接口,测试时换成 mock。前端对后端返回的数据做了防御式校验,DTO 字段一变,测试会先报错,不会把错误状态悄悄塞进界面。
凭据怎么存
SSH 密码和私钥口令是敏感信息,Via 的处理分两层:
- 用户设置主密码时,用 Argon2 派生包装密钥,把数据密钥用 XChaCha20-Poly1305 加密后写进 SQLite。
- 首次初始化生成 10 个恢复码,只显示一次;忘记主密码时用一个未使用的恢复码重置。
- 用户不设主密码时,密码和私钥口令只留在内存,不落盘,重启后重新输入。
导出配置时永远不含密码、私钥口令、私钥内容。导出的 JSON 只有分组、会话的非敏感字段和转发规则,可以直接分享给同事导入,这是当时最想要的「把配置分享给他人」的能力。
主机密钥信任
首次连一台跳板机,Via 会要求确认主机密钥指纹;之后指纹变了就阻断连接。这一步防中间人攻击,思路和 ssh 的 known_hosts 一样。
状态机与自动重连
断线重连是最容易出问题的地方。Via 把重连逻辑放在前后两端,必须保持同步:
- Rust 后端
TunnelManager持有五个 HashMap:会话、规则、运行中的 Forwarder、手动停止的规则 id、每条规则的状态。 - 传输层断掉时,非手动停止的规则进入
reconnecting,按2^attempt退避重连,退避间隔封顶 60 秒。 - 前端收到
reconnecting事件后,用同样的退避策略重新调connect_session+start_enabled_rules。
手动停止被两端都记着(后端 manually_stopped,前端 reconnectingSessions),所以用户主动停掉的规则不会被自动重连拉起来。
rust
pub const fn reconnect_delay_seconds(attempt: u32) -> u64 {
match attempt {
0..=5 => 1_u64 << attempt, // 1, 2, 4, 8, 16, 32 秒
_ => 60,
}
}
性能
转发数据面用 Criterion 在 127.0.0.1 回环上测过,走的是 Forwarder 的实际数据路径(accept 循环 + copy_bidirectional),SSH 会话用 mock 实现:
- 峰值单向吞吐量 3.08 GiB/s(16 MiB 批量,中位数)
- 数据面往返延迟 32.5 µs(1 字节 echo,中位数)
Via 在数据拷贝这一环几乎没有额外开销,延迟和吞吐主要受网络和 SSH 限制。benchmark 代码在 src-tauri/benches/forwarder.rs,跑 make bench 可以复现。

取舍与边界
Via 只做 Local Forwarding,-R(Remote)和 -D(Dynamic/SOCKS5)都不做,终端、SFTP、云端同步、跨平台也都不在 V1 范围。这个取舍的理由是:把一个需求做窄,才能在统一管理、状态展示和重连这些地方做得够细。V1 先把「管理和记录本地转发」这一件事做好。
当前只官方支持 macOS,Windows x64 和 Linux x64 会随 CI 构建产物发布,但未测试。
获取与使用
还在用 bash 脚本维护 SSH 转发的,可以试试 Via。源码、截图和 benchmark 都在仓库里:
GitHub:https://github.com/colinrs/via
本地跑起来只要两步:
bash
make install
make dev
make dev 会启动 Vite 开发服务器和 Tauri 桌面窗口。发布产物是 macOS .app,本地 make package 会生成 ad-hoc 签名的包,对外分发前再换 Apple Developer ID 重新签名并公证。