我用 Rust 写了一个 SSH 本地端口转发管理器:Via 的实现与取舍

工作中访问内网的数据库、缓存和搜索服务,我一直在跳板机后面开 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 里,整个流程只有四步:

  1. 127.0.0.1:<local_port> 上 bind 一个 TcpListener
  2. 每 accept 到一个本地连接,通过已认证的 SSH 会话 open_direct_tcpip 打开一条到 target_host:target_port 的通道。
  3. 在本端连接和 SSH 通道之间 copy_bidirectional 双向拷贝字节。
  4. 字节流就透明地流到了内网服务。

关键代码的骨架如下,重点看 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 会要求确认主机密钥指纹;之后指纹变了就阻断连接。这一步防中间人攻击,思路和 sshknown_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 重新签名并公证。

相关推荐
智嵌研习社2 小时前
Ollama 本地大模型完全配置指南:Modelfile 参数与系统环境变量深度解析
java·开发语言
XZ-0700012 小时前
1-1-可视化-练习
开发语言·python
橙橙笔记2 小时前
Python学习第三部分
开发语言·python·学习
huainingning3 小时前
个人版WorkBuddy编写设备巡检并导出设备配置python脚本
开发语言·python
Java后端的Ai之路3 小时前
02、Python普通工厂模式
开发语言·人工智能·python·设计模式·普通工厂模式
韶博雅3 小时前
开启补充日志
java·开发语言·sql
被怪兽吃掉了4 小时前
5.2.1一维组数定义方式
开发语言·c++·算法
TheBestRucy4 小时前
Python 九阳神功之肆:网络编程 · Socket 从入门到实战
开发语言·网络·python
j7~4 小时前
【C++】《C++二叉搜索树(BST)从入门到精通:概念、实现与Key/Value模型全解析》
开发语言·c++·学习·二叉搜索树
hehelm4 小时前
仿muduo库实现高并发服务器—Channel类
linux·服务器·开发语言·网络·c++