隧道只有一种——Shellby 的端口转发
Shellby 复盘:本地转发、远程转发、动态 SOCKS、多跳跳板,四种形态,一个统一的隧道模型,外加一个 NIO 的坑。
Shellby 支持四种 SSH 端口转发:本地 -L、远程 -R、动态 -D(SOCKS 代理)、多跳 -J(ProxyJump 跳板)。这篇讲怎么把这四种形态收进一个统一的隧道模型,以及做双向转发时踩到的一个 NIO 事件循环的坑。
一个决定:隧道不依附终端
先说最重要的架构决定:所有隧道都是独立、常驻、自动重连的专用 SSH 连接,不依附任何终端会话。
很多 SSH 客户端把端口转发做成「终端连接的附属品」——你开一个 shell,顺带在这条连接上开个转发,关了 shell 转发也没了。Shellby 反过来:隧道有自己专用的 SSH 连接,你关掉所有终端,隧道照样在跑。
为什么?因为端口转发的典型用法是「后台常驻」——把数据库端口转到本地、开个 SOCKS 代理挂着,这些你希望它一直活着,而不是绑在某个恰好开着的终端窗口上。把隧道和终端会话解耦,隧道才能有自己的生命周期:独立启动、独立重连、独立计流量。
代价是隧道要自己管一套连接。App 级有一个隧道管理器(跨窗口共享),管理每条独立隧道的状态机(连接中 / 活跃 / 失败)、SSH 句柄、以及一个「重建闭包」。建隧道时只 connect + startForward,不开 shell、不建终端会话——它就是一条纯粹用来转发的连接。
四种转发,一个协议
四种转发统一经一个 Forward 协议,由会话按转发类型分派。它们的实现差异挺大:
- 本地 -L:起一个
ServerBootstrap监听本地端口,每个入站连接就在 SSH 上开一条 directTCPIP 通道,两端配一对GlueHandler做双向转发。 - 动态 -D(SOCKS5):手写了 RFC 1928 的一个子集——只做无认证、只做 CONNECT、支持 IPv4/域名/IPv6,
BIND和UDP ASSOCIATE直接回「不支持」。握手完成后把 SOCKS handler 从 pipeline 摘掉、换上 glue 转发。 - 远程 -R:复用 Citadel 的高层远程转发能力,放进一个可取消的 Task 里,停止就取消 Task,让 Citadel 内部发
cancel-tcpip-forward。 - 多跳 -J:见下一节。
一个手写 SOCKS 的细节坑:握手完成的那一刻,客户端可能已经把应用数据跟握手包一起发过来了(pipelined),换 handler 时得把这段 leftover 数据原样带走、写给目标,不能丢。这类「协议切换瞬间的残留数据」是手写网络协议最容易漏的地方。
还有个上架合规的注意点:动态 SOCKS 明确定位为开发运维用途,不能宣传成翻墙/绕过网络限制——所以只做了开发运维够用的子集,不做 UDP、不做 BIND。
NIO 的坑:跨 EventLoop 写要切回对端的 loop
双向转发的核心是那对 GlueHandler:本地端读到的数据写给远端,远端读到的写给本地。听起来简单,但踩了个 NIO 的坑。
问题是:转发的两端可能跑在不同的 EventLoop 上。当一端读到数据、要写给另一端时,如果直接在当前 loop 上调对端 channel 的写——NIO 会静默丢弃,或者行为未定义。因为 NIO 的规矩是:一个 channel 的操作必须在它自己所属的那个 EventLoop 上执行。
修法是转发写数据时,必须用 loop.execute 把这个写操作切到对端 channel 自己的 EventLoop 上再执行。一行 execute,但不加的话表现是「转发时好时坏、丢数据」这种最难查的症状——因为它不报错,就是悄悄丢。
用异步网络框架,「在哪个线程/循环上执行」和「执行什么」一样重要。 框架的并发模型不是背景知识,是你写每一行 IO 代码时都得记着的约束。
还有个装 handler 时序的坑:本地转发要在通道初始化的回调里就把 SSH 侧的 glue 装好,不能等通道激活后再装——否则通道刚激活时的头几个字节会在 handler 还没就位时到达、被丢掉。
多跳跳板:逐跳认证,还要防环
ProxyJump 多跳(本机 → 跳板 A → 跳板 B → 目标)的实现是:直连第一跳,然后逐跳「跳」过去——在当前连接上开一条到下一跳的 directTCPIP 通道、完成 SSH 握手,一跳接一跳,最后跳到目标。每一跳独立认证、独立校验主机指纹。 某一跳失败时,错误里标清楚是第几跳、哪台主机失败的——多跳链路排障,不告诉你断在哪一环等于没法查。
跳板链是从主机配置里的「跳板主机」字段往上溯构建的,这里得防环:用一个已见集合沿链上溯,撞到重复的主机 ID 就抛「跳板链存在环路」,而不是无限递归下去。用户配置是可能配出环的(A 的跳板是 B、B 的跳板又是 A),代码不能信任配置无环。
重连:首连失败和断线要区别对待
独立隧道的自动重连有个反直觉但很重要的区分:首连失败不自动重连,连通过一次的断线才自动重连。
逻辑是:首连成功后起一个长驻 Task,每 10 秒探一次连接是否还在,断了就指数退避重连(2 秒起,翻倍,封顶 30 秒)。但第一次就连不上的隧道——多半是端口冲突、配置写错这类问题——直接标记为失败,不自动重连,让用户去修正。
为什么这么设计?因为「连过一次然后断了」大概率是网络抖动/服务器重启这类临时问题,重连有意义;而「一次都没连上」大概率是配置问题,无脑重连只会一遍遍撞同一堵墙、刷失败日志。区分「临时故障」和「配置错误」,前者重试、后者停下报错让人修——这个区分能省掉大量无意义的重连风暴。
诚实的短板:-R 统计不了流量
隧道面板会显示每条隧道的实时上下行速率。但有一类隧道显示的是「—」:远程转发 -R。
因为流量统计是靠双向转发必经的 GlueHandler 来计数的——数据从这里过,就顺手加一笔。本地 -L 和动态 -D 都经过 glue,能统计;但远程 -R 走的是 Citadel 的高层能力,数据不经过我们自建的 glue,统计不到。
这时诚实地显示「—」,而不是编一个假数字。做不到的功能,标清楚做不到,比糊弄一个看起来能用的数字更可信——用户看到「—」知道这里没数据,看到一个假数字却会拿它做判断。
小结
做端口转发,几条带走的:
- 隧道独立于终端:想让转发能后台常驻、有自己的生命周期,就别把它绑在终端会话上;
- 异步网络框架里,「在哪执行」是一等约束:NIO 跨 EventLoop 写必须
loop.execute切回对端 loop,不然静默丢数据; - 别信任用户配置无环:多跳跳板要主动防环、逐跳标清失败位置;
- 区分临时故障与配置错误:断线重连、首连失败停下报错,别让配置问题触发重连风暴;
- 做不到就诚实标出来:-R 统计不了流量就显示「—」,不编假数字。
留言