做文件对接的时候遇到这个问题,真的是很典型的“一眼懵”错误。明明代码写法看着没问题,控制台却抛出一句Remote host terminated the handshake,第一反应是网络不通,第二反应是防火墙,排查半天才发现问题出在 FTPS 连接本身的 TLS 握手环节。
如果你也在用 Apache Commons Net 的FTPSClient上传文件,碰到这个报错,建议先别急着改代码。这篇文章从握手原理讲起,把四个最常见的根因和对应的解决方案整理成了一套可落地的排查清单,最后附上可以直接改改就用的完整工具类。
1. 问题现象与初判
1.1 报错出现在哪个阶段
先确认一件事:这个异常是在调用connect()时抛出来的,还是在storeFile()传输文件时抛出来的?
这两个阶段出现相同报错,指向的问题完全不同。
- 连接阶段报错:说明控制通道的 TLS 握手就没成功。客户端发出
ClientHello,服务器没有正常响应ServerHello,直接把 TCP 连接掐断了。常见的组合是显式/隐式模式选错、端口错误、TLS 版本协商失败、服务器不信任客户端等。 - 传输阶段报错:控制通道是好的,用户也能正常登录,但当客户端尝试打开数据连接并再次执行 TLS 握手时,服务器中断了握手。这种情况多半不是证书问题,而是数据连接保护级别没设置,比如漏了
execProt("P")。
我用FTPSClient踩过两次坑,一次在 21 端口上连着显式 FTPS 服务器,直接用隐式模式去连,结果服务器根本没进入 TLS 流程,直接断开;另一次是控制通道握手成功、登录也过了,但在storeFile时数据通道握手失败,排查下来是数据连接保护级别没有设置。两类问题虽然现象一样,但修复方式完全不同。
1.2 先给结论:四类根因
我把“Remote host terminated the handshake”这个问题拆成四个排查方向,绝大多数场景跑不出这几个范围:
| 根因方向 | 典型表现 | 验证手段 |
|---|---|---|
| 模式与端口不匹配 | 隐式/显式选错,连错端口 | 查看服务器文档,确认对方是 Implicit 还是 Explicit FTPS |
| TLS 协议版本协商失败 | 服务器只支持 TLSv1.2,客户端用默认 TLS 版本,可能尝试了更高版本 | 开启 JVM SSL 调试日志,或使用 openssl 验证服务器支持的版本 |
| 证书信任链问题 | 服务器用自签名证书,客户端默认信任库中没有该证书 | 使用 openssl 查看证书链,检查是否由受信任 CA 签发 |
| 数据连接保护级别 | 控制通道没问题,数据通道握手失败 | 检查是否执行了execPbsz(0)和execProt("P") |
把这四个方向理清了,问题基本能锁定。
2. FTPS 握手机制,读懂这条报错的底层逻辑
2.1 显式 FTPS 和隐式 FTPS,别搞混
FTPS 有两种工作模式,区别在于 TLS 握手的时机。
显式 FTPS(Explicit FTPS)
默认端口是 21。客户端先与服务器建立普通 TCP 连接,服务器返回 220 就绪状态,然后客户端发送AUTH TLS命令,服务器返回 234 表示允许切换,之后客户端在这个连接上发起 TLS 握手。握手成功后,后续的USER、PASS、PASV、STOR等命令全部走加密通道。
隐式 FTPS(Implicit FTPS)
默认端口是 990。客户端连接上以后不需要发送任何 FTP 命令,直接开始 TLS 握手。服务器从一开始就假设这是一条加密通道。
在 Apache Commons Net 中,这两个模式对应两个不同的构造方式:
// 显式 FTPS FTPSClient explicitClient = new FTPSClient(false); // 隐式 FTPS FTPSClient implicitClient = new FTPSClient(true);如果选错了模式,最直接的结果就是在握手阶段被服务器终止。比如连 990 端口但用了显式模式,客户端会先发 FTP 命令等待 220 响应,而服务器在等 TLS ClientHello,双方鸡同鸭讲,服务器直接断开连接。反之亦然。
2.2 一次完整握手的正常流程
不管是显式还是隐式模式,TLS 握手本身都要走这几步:
- 客户端发送
ClientHello,携带支持的协议版本、密码套件列表、随机数等信息。 - 服务器返回
ServerHello,选定协议版本和密码套件。 - 服务器发送证书(如果要求客户端认证,还会请求客户端证书)。
- 客户端验证服务器证书。
- 双方交换密钥参数,完成握手,之后开始传输加密数据。
“Remote host terminated the handshake”这个错误,翻译过来就是“远程主机在中途终止了握手”。意思是 TCP 连接已经建立,但 TLS 握手没有完成,服务器主动关闭了连接。这通常发生在第 1 步到第 3 步之间。
从服务器视角来看,终止握手的原因无非是以下几种:
- 客户端发送的
ClientHello中,没有服务器支持的协议版本。 - 客户端发送的
ClientHello中,没有服务器支持的密码套件。 - 客户端不信任服务器提供的证书,服务器端检测到握手失败,主动断开。
- 服务器要求客户端证书,但客户端没有提供。
- 服务器配置了特殊的安全策略,比如只允许特定来源 IP 的握手。
所以,排查这个报错的核心,就是搞清楚服务器到底在哪个环节拒绝了你。
3. 从现象反推根因:四个方向逐一排查
3.1 模式与端口不匹配,最常见的低级错误
这个原因出现频率最高,也最容易定位。
对接外部系统时,对方给出的技术文档里通常会写明“FTPS 连接(显式/隐式)”和端口号。但实际操作中,文档不完整或者对接人自己也不清楚的情况很常见。
我的做法是先用命令行工具验证,再写代码。
验证隐式 FTPS 端口:
openssl s_client -connect <服务器IP>:990 -tls1_2 -servername <服务器域名>如果命令执行后能看到CONNECTED以及后面的证书信息,说明这个端口确实是隐式 FTPS,且服务器支持 TLSv1.2。如果报错或者直接卡住,则要么是端口不对,要么是协议版本不对。
验证显式 FTPS 端口:
openssl s_client -connect <服务器IP>:21 -starttls ftpopenssl的-starttls ftp参数会先发送 FTP 的AUTH TLS命令,再开始 TLS 握手。如果这个命令能成功拿到证书,说明 21 端口是显式 FTPS。
在代码里,对应的做法就是:
FTPSClient client = new FTPSClient(false); // 显式,连 21 // 或 FTPSClient client = new FTPSClient(true); // 隐式,连 990如果发现接的是 21 端口但服务器确实是隐式模式,直接改成:
FTPSClient client = new FTPSClient(true); client.connect(host, 990);提示:不是所有隐式 FTPS 服务器都用 990 端口,也不是所有显式 FTPS 服务器都用 21 端口。对接时优先看文档,没有文档就以对方提供的端口号为准,再用 openssl 验证。
3.2 TLS 版本与密码套件协商失败
JDK 默认的 TLS 行为并不总是符合服务器的要求。
JDK 8 默认启用 TLSv1.0 到 TLSv1.2;JDK 11 开始默认启用 TLSv1.3。如果服务器只支持 TLSv1.2,而客户端用的 JDK 默认尝试 TLSv1.3,服务器有可能直接拒绝握手。反之,如果服务器是较老的环境,只支持 TLSv1.0,而客户端默认只尝试高版本,也会握手失败。
这种问题单靠改FTPSClient的构造参数还不够,需要显式指定协议版本:
// 指定使用 TLSv1.2 FTPSClient client = new FTPSClient("TLSv1.2", false); // 或者使用 SSLContext 指定 SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); sslContext.init(null, trustManagers, new SecureRandom()); FTPSClient client = new FTPSClient(false, sslContext);这里有一个细节要注意:SSLContext.getInstance("TLS")会使用 JVM 默认的最高可用 TLS 版本,不一定等同于 TLSv1.2。如果需要明确指定协议版本,直接传"TLSv1.2"更可靠。
另外,FTPSClient还提供了设置协议和密码套件的方法:
client.setEnabledProtocols(new String[]{"TLSv1.2"}); client.setEnabledCipherSuites(new String[]{"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256"});不过说实话,手动指定密码套件是双刃剑。只有在服务器明确限制套件时才值得这么做。大多数情况下,设置正确的协议版本就够用了。
怎么确认服务器到底支持哪些版本?还是用 openssl:
openssl s_client -connect <服务器IP>:990 -tls1_0 # 测 TLSv1.0 openssl s_client -connect <服务器IP>:990 -tls1_1 # 测 TLSv1.1 openssl s_client -connect <服务器IP>:990 -tls1_2 # 测 TLSv1.2 openssl s_client -connect <服务器IP>:990 -tls1_3 # 测 TLSv1.3哪个版本能成功,代码里就用哪个。
3.3 证书信任链问题,最容易被忽略
服务器使用自签名证书,或者使用的是内网私有 CA 签发的证书,客户端默认信任库中找不到对应的根证书,握手就会失败。
这种失败有两种表现形式:
- 直接抛
PKIX path building failed,提示证书校验失败。 - 服务器在检测到客户端不信任证书后主动断开连接,表现就是
Remote host terminated the handshake。
两种表现都需要处理证书信任问题。解决方案有两种:要么把证书导入 JVM 的信任库,要么在代码里自定义TrustManager。
生产环境建议走证书导入信任库的方式。但如果你是快速联调,临时绕过证书校验会更方便。下面这段代码就是常用的“跳过证书校验”写法:
TrustManager[] trustAllCerts = new TrustManager[]{ new X509TrustManager() { @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) { } } };在FTPSClient上,可以直接设置TrustManager:
client.setTrustManager(trustAllCerts[0]);这个方法比手动构造SSLContext再传进去要简洁,FTPSClient内部会用自己的方式组合出合适的SSLContext。
注意:跳过证书校验只适合测试环境。正式环境如果这么做,相当于把数据明文暴露给中间人,安全风险极高。正规做法是把服务器证书导入
cacerts,或者使用自定义TrustManager实现针对特定证书的校验逻辑。
3.4 数据连接保护级别未设置
如果你的connect()和login()都能正常完成,唯独在storeFile()时报握手被终止,那么大概率是数据连接保护级别的问题。
FTP 协议有两条通道:控制通道(用于发命令)和数据通道(用于传文件)。FTPS 要求控制通道必须加密,但数据通道是否加密,由PROT命令决定。
PROT P:数据通道也加密,这是大多数 FTPS 服务器的要求。PROT C:数据通道保持明文,实际中很少用。
在代码里,正确的顺序是在登录之后、传输文件之前,先执行两条命令:
client.execPbsz(0); client.execProt("P");根据 RFC 4217,PBSZ必须设置为 0。有些服务器如果不先设置 PBSZ,就无法执行 PROT。
为什么顺序这么重要?因为 FTPSClient 在执行execProt("P")时会记录当前数据连接的保护级别,后续打开数据连接时会根据这个级别决定是否对数据通道做 TLS 握手。如果漏了这一步,数据通道会按明文方式打开,一旦服务器要求加密,握手必然失败。
另外提一句:execPbsz和execProt的返回值一定要检查。有些服务器可能返回 false,但代码没判断,后续照样去传输文件,结果又卡在握手阶段。检查返回值是省事的手段:
if (!client.execPbsz(0)) { throw new IOException("PBSZ 0 command failed"); } if (!client.execProt("P")) { throw new IOException("PROT P command failed"); }4. 可复用的解决方案
4.1 完整的 FTPS 上传工具类
把上面这些经验整合起来,我给你的建议是封装一个支持显式/隐式模式、支持 TLS 版本配置、支持自定义 TrustManager 的工具类。这样对接不同服务器时,只需要调整参数,不用每次从头排查。
import org.apache.commons.net.PrintCommandListener; import org.apache.commons.net.ftp.FTP; import org.apache.commons.net.ftp.FTPReply; import org.apache.commons.net.ftp.FTPSClient; import javax.net.ssl.*; import java.io.*; import java.security.SecureRandom; import java.security.cert.X509Certificate; public class FtpsUploader { private FTPSClient ftpsClient; public FtpsUploader(boolean implicit, String protocol) { // protocol 示例:"TLSv1.2" // implicit 为 true 时连接隐式 FTPS 端口(一般是 990) ftpsClient = new FTPSClient(protocol, implicit); } /** * 配置信任所有证书,仅用于测试环境联调 */ public void trustAllCertificates() { TrustManager[] trustAllCerts = new TrustManager[]{ new X509TrustManager() { @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) { } } }; ftpsClient.setTrustManager(trustAllCerts[0]); } public boolean connect(String host, int port, String username, String password) throws IOException { // 打印控制通道的 FTP 命令和响应,方便排错 ftpsClient.addProtocolCommandListener(new PrintCommandListener(new PrintWriter(System.out))); ftpsClient.setConnectTimeout(15000); ftpsClient.setSoTimeout(30000); ftpsClient.setControlKeepAliveTimeout(300); try { ftpsClient.connect(host, port); int reply = ftpsClient.getReplyCode(); if (!FTPReply.isPositiveCompletion(reply)) { ftpsClient.disconnect(); throw new IOException("服务器拒绝连接, reply code: " + reply); } // 对于隐式 FTPS,connect 之后已经完成握手。 // 对于显式 FTPS,connect 内部已发送 AUTH TLS 并完成握手。 if (!ftpsClient.login(username, password)) { throw new IOException("登录失败"); } // 设置被动模式 ftpsClient.enterLocalPassiveMode(); // 二进制传输,避免文本模式导致文件内容被改写 ftpsClient.setFileType(FTP.BINARY_FILE_TYPE); // 统一使用 UTF-8,避免中文目录/文件名乱码 ftpsClient.setControlEncoding("UTF-8"); ftpsClient.setAutodetectUTF8(true); // 数据连接保护级别,必须先 PBSZ 再 PROT if (!ftpsClient.execPbsz(0)) { throw new IOException("PBSZ 0 执行失败"); } if (!ftpsClient.execProt("P")) { throw new IOException("PROT P 执行失败"); } return true; } catch (IOException e) { if (ftpsClient.isConnected()) { ftpsClient.disconnect(); } throw e; } } public boolean uploadFile(String localFilePath, String remoteFilename) throws IOException { try (InputStream inputStream = new FileInputStream(localFilePath)) { return ftpsClient.storeFile(remoteFilename, inputStream); } } public void disconnect() throws IOException { if (ftpsClient.isConnected()) { ftpsClient.logout(); ftpsClient.disconnect(); } } }使用示例:
FtpsUploader uploader = new FtpsUploader(true, "TLSv1.2"); uploader.trustAllCertificates(); // 测试环境才这样用 boolean connected = uploader.connect("192.0.2.10", 990, "ftpuser", "ftppass"); if (connected) { boolean uploaded = uploader.uploadFile("/tmp/daily_report.zip", "/remote/2025/daily_report.zip"); System.out.println("upload result: " + uploaded); } uploader.disconnect();这个工具类的核心思路是把连接参数统一封装,遇到新的 FTPS 服务器时,改implicit、protocol、host、port这几个参数就能适配。
4.2 开启调试日志,用 openssl 验证服务器
代码写好了,但如果还是连不上,就进入调试模式。
在跑调试之前,先把 JVM 的 SSL 调试日志打开。在启动参数里加:
-Djavax.net.debug=ssl:handshake:verbose或者在代码里动态设置:
System.setProperty("javax.net.debug", "ssl:handshake:verbose");打开之后,控制台会输出详细的手握过程。重点关注这几个节点:
ClientHello发送后,是否收到ServerHello。如果只看到TCP connection closed by peer,说明服务器在收到ClientHello后直接断开,基本可以判断是协议版本或密码套件协商失败。- 是否出现
Received fatal alert: handshake_failure。这是服务器明确告诉客户端“我不支持你提供的参数”,方向同样是协议或套件问题。 - 是否出现
PKIX path building failed。这是证书信任问题。
配合 openssl 验证服务器,能更快速地确定服务器的能力边界。比如用下面这条命令,能直接看到服务器支持的协议版本和证书信息:
openssl s_client -connect 192.0.2.10:990 -tls1_2如果 openssl 能连上而 Java 连不上,说明问题多半出在客户端的 TLS 版本或密码套件配置上;如果 openssl 也连不上,问题就在服务器端或者网络层。
5. 实战排错记录与速查表
5.1 我遇到过的两个高复现场景
场景一:只改端口,模式没改。
当时对接一个业务系统,对方给的文档里写“端口 990”,我第一反应是隐式 FTPS,直接用new FTPSClient(true)去连。结果连不上,报Remote host terminated the handshake。
用 openssl 测了一下端口,发现竟然能拿到证书,但 Java 连不上。仔细看 openssl 输出里的CONNECTED之后,服务器实际返回的是普通 FTP 220 响应,这说明服务器根本不是隐式模式,而是显式 FTPS 部署在 990 端口上。
改成new FTPSClient(false)并连 990 端口,问题直接解决。
这里要提个醒:端口号只能作为参考,不能作为判断模式的依据。你要做的就是用 openssl 验证,或者直接用new FTPSClient(true)和new FTPSClient(false)各试一次,配合日志看哪种方式能握手成功。
场景二:控制通道握手成功,数据通道握手失败。
另一个项目里,connect、login、PASV 全正常,到storeFile时抛Remote host terminated the handshake。
查看日志发现控制通道的 TLS 握手已经完成,但数据连接建立时服务器主动断开。对照代码,发现只写了execPbsz(0),忘了写execProt("P")。补上之后,数据通道的 TLS 握手正常,文件传输成功。
5.2 常见问题排查速查表
| 现象 | 可能原因 | 修复方案 |
|---|---|---|
connect()时报握手被终止 | 隐式/显式模式选错 | 用 openssl 验证端口实际的模式,再修改FTPSClient(boolean)参数 |
connect()时报握手被终止 | 端口错误 | 确认对端端口,隐式标准端口 990,显式标准端口 21 |
connect()时报握手被终止 | TLS 版本不匹配 | new FTPSClient("TLSv1.2", false)指定协议版本,或用 openssl 测试服务器支持的版本 |
login()之前握手被终止 | 服务器要求客户端证书 | 配置KeyManager,调用client.setKeyManager(...) |
storeFile()时报握手被终止 | 数据连接保护级别未设置 | 登录后先execPbsz(0)再execProt("P") |
storeFile()时报握手被终止 | 被动/主动模式问题 | 调用enterLocalPassiveMode(),并在 NAT/防火墙环境确认被动模式端口可通行 |
握手时报PKIX path building failed | 服务器证书不受信任 | 导入证书到信任库,或测试环境临时信任所有证书 |
5.3 最后再补充几个易错细节
控制连接超时和读取超时要设置合理。默认情况下,FTPSClient的setConnectTimeout可能为 0,也就是无限等待,这在网络不稳定时会卡很久。我习惯把连接超时设为 10~15 秒,读取超时设 30 秒,既不给服务器压力,也能快速暴露问题。
另外,数据连接的超时和保持连接机制很容易被忽略。传输大文件时,如果长时间没有命令交互,某些服务器或中间设备会自动断开会话。setControlKeepAliveTimeout(300)可以在传输期间定期发送 NOOP 命令保持控制通道活跃,避免传输中断。这个方法我在传几百 MB 文件时实测过,确实有效。
文件名的字符编码也要留意。虽然 FTPS 本身是二进制协议,但控制通道传输命令时仍然使用字符编码。如果服务器是 Windows 系统,默认可能是 GBK;Linux 系统一般是 UTF-8。代码里统一用 UTF-8,并调用setAutodetectUTF8(true),大多数场景都能正常处理。
关于 Apache Commons Net 的版本,建议使用 3.9 以上版本。旧版本在 TLSv1.3 支持和部分 TLS 扩展上存在兼容性问题,升级到 3.10.0 或更高版本可以规避不少莫名其妙的握手异常。
Maven 依赖参考:
<dependency> <groupId>commons-net</groupId> <artifactId>commons-net</artifactId> <version>3.10.0</version> </dependency>另外,调试的时候有没有打开PrintCommandListener也很关键。它能把客户端发送的 FTP 命令和服务器返回的响应原样打印出来。很多握手失败前的蛛丝马迹,比如服务器返回的 220 欢迎语、AUTH 响应的 234 等,都能从这个输出里看到。加上这个监听器,配合 JVM 的 SSL 调试日志,基本没有定位不了的问题。
个人经验是,遇到Remote host terminated the handshake,不要慌,先从“模式对不对”“端口对不对”这两个最基础的问题查起,再上调试日志,最后才是改代码。这个顺序能帮你省下至少半天时间。