通过 Ruby Net::FTP 上传或下载文件时,控制连接通常很快就能建立,用户名密码校验也正常,但只要执行 put、get、ls 这类需要数据连接的操作,程序就可能长时间无响应,最终抛出 Errno::ETIMEDOUT 或 Net::FTPPermError。如果抓包会发现,客户端发送 PORT 命令后,服务器回包 200 PORT command successful,随后服务器主动发起的 SYN 包被客户端防火墙悄悄丢弃。这种现象通常不是 FTP 服务端配置错误,而是主动模式与防火墙、NAT 设备之间的兼容问题。

一、主动模式数据连接为什么容易被防火墙阻断
FTP 会话包含两条独立的 TCP 连接:一条是客户端发起的控制连接,默认连到服务器的 21 端口;另一条是数据连接,用于传输目录列表和文件内容。主动模式的特点在数据连接的方向相反:客户端通过 PORT 或 EPRT 命令告诉服务器自己监听的临时地址和端口,然后服务器从 20 端口主动向客户端发起 TCP 连接。PORT 命令携带的是类似 192,168,50,10,213,117 的格式,前四段是 IP 地址的十进制写法,后两段用 p1 乘 256 加 p2 计算端口号。
在企业防火墙、云安全组或家用 NAT 后面,这条入站连接几乎一定会被拦截。原因很直接:防火墙默认只允许内部主动发起的出站流量和对应的应答包,不信任外部主动发起的连接。即便是 NAT 环境,PORT 命令里携带的可能是内网地址,服务器收到后也无法正确路由回客户端。Ruby Net::FTP 默认 passive 属性为 false,也就是主动模式,所以未显式设置时所有数据操作都会踩到这个坑。
require 'net/ftp'
ftp = Net::FTP.new
ftp.connect('203.0.113.10', 21)
ftp.login('ftpuser', 'secret')
puts ftp.passive # => false
# 执行下面的操作会等待数据连接,可能超时
ftp.ls
ftp.close
上面代码中的 ftp.ls 会触发数据连接建立过程。如果客户端防火墙拦截服务器发来的 SYN,ls 可能长时间阻塞,直到 read_timeout 超时。有些运营商或企业网设备甚至不会简单丢包,而是返回 ICMP 不可达或 RST,这时异常会表现为 Errno::ECONNREFUSED 或 Errno::EHOSTUNREACH。
二、切换被动模式:最直接的恢复方式
被动模式把数据连接的方向反转回来。客户端发送 PASV 命令后,服务器会打开一个临时监听端口,并通过控制连接把 IP 和端口返回给客户端,然后由客户端主动发起出站连接。这个方向与普通出站访问完全一致,因此 NAT 和防火墙通常可以直接放行。对 Ruby 代码来说,修复往往只需要在 connect 之前加一行 ftp.passive = true。
require 'net/ftp'
ftp = Net::FTP.new
ftp.passive = true
ftp.connect('203.0.113.10', 21)
ftp.login('ftpuser', 'secret')
ftp.chdir('/upload')
ftp.putbinaryfile(File.open('backup.tar.gz'), 'backup.tar.gz')
ftp.close
要注意 passive 属性最好在建立连接前设置。虽然连接后修改也可能影响后续数据连接,但不同版本 Net::FTP 行为不完全一致,显式设置在 connect 之前最稳妥。如果是从配置文件读取连接参数,也可以把被动模式作为默认值写入,避免迁移脚本时再次遇到同样问题。
被动模式并不是银弹。某些较老或配置不完善的 FTP 服务器只开放了很小的被动端口范围,或者 PASV 响应里返回的是服务器私网地址。防火墙如果对出站目标端口限制过严,高位临时端口也可能被拦。遇到这些场景,单纯的 passive = true 仍然会失败,需要结合下面两种思路继续排查。
| 维度 | 主动模式 | 被动模式 |
|---|---|---|
| 数据连接发起方 | 服务器 | 客户端 |
| 主要端口方向 | 入站到客户端随机端口 | 出站到服务器随机端口 |
| NAT 和防火墙友好度 | 较差,常被拦截 | 较好,类似普通访问 |
| Ruby Net::FTP 配置 | passive = false | passive = true |
三、必须保留主动模式时的防火墙与 NAT 配置
有些业务出于安全策略或服务端限制,必须继续使用主动模式。此时需要从网络层面解决入站连接被丢弃的问题。Linux 主机作为客户端时,可以启用连接跟踪的 FTP 协议助手。该模块会解析控制连接中的 PORT 和 EPRT 命令,自动把服务器随后发起的入站数据连接标记为 RELATED 状态,从而允许其通过 iptables。
modprobe nf_conntrack_ftp sysctl -w net.netfilter.nf_conntrack_helper=1 iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
这种方案要求控制连接是明文 FTP。如果启用了 TLS 加密,PORT 命令被加密,连接跟踪模块就无法解析,主动模式穿透会更困难。云服务器还需要在安全组中放行来自 FTP 服务器 IP 的入站规则,但时间窗口和临时端口不固定,规则很难精确配置。加上 Ruby Net::FTP 没有直接暴露主动模式数据端口范围的设置接口,通常不推荐在公网环境硬用主动模式。
更实际的替代方案是借助 VPN 或 SSH 隧道,把 FTP 控制连接和数据连接都放进同一条已经建立的加密隧道中。这样防火墙看到的只有内部主机之间的互相访问,主动模式下的入站连接也不会再被边界设备拦截。例如可以在本机建立到 FTP 服务器所在内网的隧道,再让 Net::FTP 连接隧道转发的地址。虽然增加了一层网络复杂度,但在必须使用主动模式且无法调整安全设备的环境里,这是可靠性较高的做法。
四、Net::FTP 传输异常排查与稳健错误处理
定位 FTP 数据连接失败时,第一步要区分问题发生在控制连接还是数据连接。控制连接失败通常会直接抛出连接异常,而数据连接失败往往表现为命令已发送但长时间没有响应。可以为 Net::FTP 设置超时时间,并捕获不同类型异常,避免程序无限挂起。
require 'net/ftp'
begin
ftp = Net::FTP.new
ftp.passive = true
ftp.connect_timeout = 10
ftp.open_timeout = 10
ftp.read_timeout = 30
ftp.connect('203.0.113.10', 21)
ftp.login('ftpuser', 'secret')
ftp.chdir('/download')
ftp.getbinaryfile('report.csv', 'report.csv')
rescue Net::OpenTimeout, Net::ReadTimeout => e
puts "数据连接超时,请检查防火墙策略或切换到被动模式: #{e.message}"
rescue Net::FTPPermError => e
puts "服务器拒绝了操作: #{e.message}"
rescue Errno::ECONNREFUSED => e
puts "连接被拒绝,目标端口可能未监听: #{e.message}"
ensure
ftp.close if ftp
end
如果希望查看协议交互细节,可以把 debug_mode 打开。它会输出命令和响应,帮助判断 PORT 或 PASV 走到哪一步失败。抓包则能更直观地看到服务器是否真的发来 SYN、是否被防火墙丢弃,以及 PASV 响应里的地址是否为公网可达地址。
ftp = Net::FTP.new
ftp.debug_mode = true
ftp.passive = false
ftp.connect('203.0.113.10', 21)
ftp.login('ftpuser', 'secret')
begin
ftp.ls
rescue => e
warn e.full_message
end
最后还要留意服务器日志和客户端网络环境。默认被动模式已经能解决绝大多数 Ruby Net::FTP 数据连接失败问题;如果被动模式仍失败,再检查服务端被动端口范围和客户端出站策略。只有确认业务确实不允许被动模式时,才考虑通过 FTP 连接跟踪、VPN 或隧道方案打通主动模式。把模式选择、超时参数和异常处理一起纳入配置管理,能显著减少后续维护成本。

