最近在对接某物流回执服务器时,我撞上了这个报错。业务方说新平台开了 FTPS,要求我们把历史订单文件批量回传过去。我第一反应很简单:把原来跑了好多年的 FTPClient 换成 Apache Commons Net 的 FTPSClient,改个构造方式,上传路径不变,顶多再加个证书。结果第一次联调,测试文件还没发出去,日志里就甩出一行:
javax.net.ssl.SSLException: Remote host terminated the handshake这句话信息量低到令人抓狂。它没有告诉你哪个阶段挂的,也没有告诉你服务器为什么拒绝。我最初的几个猜测——端口不通、防火墙拦截、账号密码错误——逐一排查后全部不成立。后来才发现,这一类报错几乎都是“TLS 握手层”问题:TCP 连接是通的,服务器也活着,但在双方还没完成握手前,服务器主动把 socket 关了。顺序错一个端口、少发一条命令、证书链不被识别,都会得到完全相同的异常。这篇就把完整的排查过程还原出来,尤其适合刚接触 FTPS/FTPES、又对 SSLException 没什么经验的人。
1. 这个SSLException到底在抱怨什么
1.1 “握手被中止”和“连接被重置”是两回事
很多同事看到 SSLException 的第一反应是“证书是不是过期了”,或者“是不是服务器重启了”。但其实Remote host terminated the handshake和Connection reset有本质区别。
Connection reset通常是 TCP 层的 RST,可能来自防火墙、负载均衡、或者服务端直接拒绝连接。而Remote host terminated the handshake是 TLS 层的异常。它的完整堆栈一般长这样:
javax.net.ssl.SSLException: Remote host terminated the handshake at java.base/sun.security.ssl.SSLSocketImpl.handleException(SSLSocketImpl.java:xxx) at java.base/sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:xxx) at org.apache.commons.net.ftp.FTPSClient._openDataConnection_(FTPSClient.java:xxx) Caused by: java.io.EOFException: SSL peer shut down incorrectly at java.base/sun.security.ssl.SSLTransport.decodeFatal(SSLTransport.java:xxx)注意堆栈里的EOFException: SSL peer shut down incorrectly。这说明客户端已经发起 TLS 握手,服务器在某个节点直接关闭了连接,没有按 TLS 规范返回alert报文。很多 FTP 服务端就是这么干的:不匹配就关闭,根本不给你协商的机会。
所以你不需要去猜这个异常本身的意义,它只说明一件事:TLS 会话没有建立成功,对面不陪你玩了。
1.2 为什么 TCP 连接是通的,TLS 层还是失败
要理解这一点,得先快速过一遍 FTP 与 TLS 的关系。FTP 本身是明文协议,FTPS 是在 FTP 外层包了一层 TLS。但 TLS 握手不是“登录成功之后再做”,而是在 FTP 命令开始之前就要完成。
普通的 TLS 握手流程是:客户端发 ClientHello,携带支持的协议版本、密码套件、SNI 等;服务端回 ServerHello、证书、密钥交换参数;之后双方完成密钥协商。如果客户端发过去的 ClientHello 里,协议版本不在服务端允许列表里,或者密码套件完全不匹配,服务端有两种处理方式:一是回一个handshake_failurealert,二是直接断开。很多工业级的 FTP 服务端会选择第二种。
还有一种更隐蔽的情况:客户端压根没打算发 TLS 握手包。比如我把显式 FTPS 的客户端连到了 990 端口,这个端口期望的是“连接建立后第一帧就是 TLS ClientHello”;客户端却按显式模式发了一句明文 FTP 命令AUTH TLS。服务器读到的是一串不认识的东西,自然也会断开。从客户端视角看,同样是“Remote host terminated the handshake”。
也就是说,这个异常本身不负责告诉你根因,它只告诉你问题发生在 TLS 握手收敛之前。从 TCP 到 TLS 之间那个“灰色地带”,才是排查重点。
2. 先分清FTPS与FTPES——问题往往藏在第一步
2.1 对方说“开了FTPS”,往往没说清楚是哪一种
刚开始排查时,我最先要确认的问题是:对方系统到底开的是哪种加密 FTP。FTPS 这个叫法在业务方嘴里基本是个口袋词,实际上至少能拆出两种模式:
| 对比项 | FTPS(隐式 TLS) | FTPES(显式 TLS) |
|---|---|---|
| 默认端口 | 990 | 21 |
| 连接开始 | 直接进入 TLS 握手 | 先明文 FTP 命令,再通过 AUTH TLS 升级 |
| 数据通道 | 也走 TLS,需要额外协商 | 也走 TLS,需要额外协商 |
| 常见错误 | 客户端发明文命令被断开 | 客户端直接握手被明文服务端断开 |
隐式 FTPS 是早年留下来的老方案,客户端和服务器从建立 TCP 连接那一刻起就必须走 TLS,没有任何明文阶段。显式 FTPES 则是现代更通用的做法:先以明文建立 FTP 控制连接,服务器返回 220 欢迎信息,客户端再发AUTH TLS,服务器回 234,之后才升级成加密通道。
我这次对接的对端平台就是典型的“隐隐式”混搭:业务方说“连 990 端口就行”,但他们的架构师在文档里写的是“FTPS 端口 990”。代码里如果只写了new FTPSClient()然后connect(host, 990),那其实是用显式模式连隐式端口,第一帧就发错了,握手直接被掐断。
2.2 端口号是排查的第一个线索
排查这类问题,我建议先别急着打开 IDE,先看一下端口号和客户端的隐式开关。
Java 的 Apache Commons Net FTPSClient 构造方式直接决定协议模式:
// 显式模式,相当于 FTPES。连接后要先发 AUTH TLS FTPSClient client = new FTPSClient(); // 隐式模式,相当于 FTPS。建立连接后立即开始 TLS 握手 FTPSClient clientImplicit = new FTPSClient(true);如果你的服务商给的是 990 端口,那必须用new FTPSClient(true);如果给的是 21 端口,并且服务器支持AUTH TLS,那用默认无参构造即可。
我这里当时犯的错就是:对方给了 990 端口,我用了默认构造。结果客户端第一条命令是明文AUTH TLS,而 990 端口的服务端在等 TLS ClientHello。两边都说“你这不是我要的东西”,于是握手终止。
这个错误在日志里非常具有迷惑性,因为它不是AUTH TLS命令失败,而是直接 SSLException。只有把构造方式改成new FTPSClient(true)之后,端口和模式才对齐。
3. 用openssl和FileZilla把责任边界切出来
3.1 openssl s_client直接看服务器态度
改完构造方式之后,问题并没有马上消失。新的报错变成了连接 990 端口时直接读不到任何证书信息,还是握手被终止。这时候我必须确认一件关键的事:服务器端到底有没有在 990 端口上正常提供 TLS 服务。
用 openssl 做一次最原始的握手测试最直观:
openssl s_client -connect 192.0.2.10:990 -tls1_2 -brief如果服务器行为正常,你应该能看到一行证书信息,并且最后输出CONNECTION ESTABLISHED。如果服务器直接关闭,输出通常只有CONNECTION CLOSED或者连接超时。
我用这条命令测出来的结果是:连接能建立,但没有输出任何证书,连接立刻被关闭。这说明 990 端口上可能不是标准的 TLS 服务,或者对端防火墙对 openssl 的握手特征进行了拦截。
这里有个容易误判的细节:既然 Java 报的是“握手终止”,openssl 也报“连接关闭”,很容易得出“服务器根本不开 TLS”的结论。但只要把-tls1_2参数去掉、改用默认 TLS 版本再试一次,结果就不一样了:
openssl s_client -connect 192.0.2.10:990 -brief这次能看到服务端证书链,也能看到SSL-Session信息。说明服务端实际只接受了某几个 TLS 版本或密码套件,而 Java 默认构造出来的握手参数里包含了一些它不想要的东西。
这种“用不同 TLS 参数测试结果完全不同”的现象,基本可以确认责任边界在 TLS 版本/密码套件协商,不在网络连通性。
3.2 FileZilla一次成功连接给出的关键信息
确认服务器愿意做 TLS 握手之后,我好几个同事的建议都是“你看看 FileZilla 能不能连”。其实这一步很有价值,哪怕只是用一次图形界面连接,也能把问题压缩一半。
用 FileZilla 连接成功后的日志大概是这样的:
状态: 连接建立,等待欢迎消息... 状态: TLS 连接已建立。 状态: 已登录但 FileZilla 的日志里比 Java 多出一行重要信息:具体用的 TLS 协议版本和密码套件。我这边当时看到的是TLSv1.3,而 Java 端 FTPSClient 握手时的时候走的还是TLSv1.2。这么一比,问题就很明显了:服务端实际上强制要求或优先协商 TLSv1.3,而我的 FTPSClient 初始化没把 TLSv1.3 开进去。
有些人可能会问:Java 8 老项目里支持 TLSv1.3 吗?答案要看 JDK 版本,JDK 8 要到 8u261 之后才支持 TLSv1.3。如果生产环境还跑在 8u221 甚至更早的版本,默认根本不带 TLSv1.3,握手时只能拿 TLSv1.2 去碰运气。这时候要么升级 JDK,要么让服务端把 TLSv1.2 加进允许列表。不是所有对端都肯给你开后门,所以先自己验证清楚是哪边不支持,再去找对方沟通。
4. FTPSClient初始化里的几个隐蔽坑
4.1 构造方法的选择比你想的更重要
经过 openssl 和 FileZilla 双重验证,协议模式已经确认是隐式 FTPS,TLS 版本也不是大问题。但代码里又遇到了第三个坑:FTPSClient 构造方法本身并不只是“加密版 FTPClient”,它的协议字符串、隐式开关和 SSLContext 创建方式都会影响实际握手。
Apache Commons Net 的 FTPSClient 常见构造方式实际有这几种:
// 显式 FTPES,默认 SSLContext,默认协议 TLS FTPSClient ftps1 = new FTPSClient(); // 隐式 FTPS FTPSClient ftps2 = new FTPSClient(true); // 指定协议名称,隐式模式 FTPSClient ftps3 = new FTPSClient("TLS", true); // 直接把外部准备好的 SSLContext 传进来 SSLContext sslContext = SSLContext.getInstance("TLS"); FTPSClient ftps4 = new FTPSClient(true, sslContext);大部分时候用new FTPSClient(true)就够了,但如果你要对端强制特定 TLS 版本,最好显式创建一个 SSLContext,再设置协议列表。不要依赖 FTPSClient 内部的默认行为,因为默认行为在不同版本、不同 JDK 下不完全一致。
我最后采用的初始化方式是:
SSLContext sslContext = SSLContext.getInstance("TLSv1.3"); sslContext.init(null, null, new SecureRandom()); FTPSClient ftps = new FTPSClient(true, sslContext); ftps.setEnabledProtocols(new String[]{"TLSv1.3"}); ftps.connect(host, 990);setEnabledProtocols这个调用特别容易漏。即使你 SSLContext 是用TLSv1.3创建的,底层 socket 上默认启用的协议还是会有差异,显式限制一下最稳妥。
4.2 数据连接丢掉了客户端证书
协议模式、TLS 版本都排查完以后,控制连接终于能走上去了:日志里能看到服务器返回 220,AUTH TLS也正常,USER / PASS登录成功。但真正上传文件的时候,STOR命令发出后,又出现了同样的Remote host terminated the handshake。
这个阶段非常典型,因为它发生在“控制连接已加密完成、数据连接重新握手”的时候。FTPS 的一大特点是控制连接和数据连接是两条独立的 TCP 连接,而且数据连接需要重新进行一次 TLS 握手。服务器如果开启了“强制客户端证书认证”,会要求数据连接里面的 TLS 握手也带上有效的客户端证书。
Apache Commons Net 的 FTPSClient 在默认情况下,数据连接会尝试复用控制连接的 SSL 会话。但如果你的环境里服务端兼容性不好,或者 JDK 的会话复用扩展没打开,数据连接就拿不到证书,服务器直接判定握手失败。
解决方法是两层配合。第一层,上传前一定要按顺序执行两个 FTPS 专用命令:
ftps.execPBSZ(0); ftps.execPROT("P");PBSZ 0表示数据缓冲长度设为 0,PROT P表示数据通道采用私密保护。这两条命令会把数据连接切到 TLS 加密模式。如果漏掉PROT P,数据连接可能是明文的,有些服务器会直接拒绝。
第二层,如果需要客户端证书,并且服务器启用了 Strict 的会话复用检查,可以显式打开 JDK 的会话工单扩展。这个设置在老项目里容易被忽略:
System.setProperty("jdk.tls.client.enableSessionTicketExtension", "true");另外顺便提一句,如果你的 FTPS 服务端允许控制连接和数据连接使用不同的 TLS 会话,那你其实可以把setHostnameVerifier和setEndpointIdentificationAlgorithm一起配好,避免证书域名校验在数据连接上误伤。
4.3 TLS版本与证书库造成的中途断开
还有一类坑是证书库问题。Java 默认信任库cacerts只信任公开 CA,很多企业自建 FTP 服务器用的是自签证书或内网 CA 签发的证书。客户端不信任对端证书时,按理说应该抛出PKIX path building failed或者unable to find valid certification path,但也有些服务端在收到客户端的失败 alert 后会直接关闭连接,最终表现又是Remote host terminated the handshake。
所以当你看到这个异常,并且前面几种模式都验证正常,下一步就该查信任库。
先看看对端证书链:
openssl s_client -connect 192.0.2.10:990 -showcerts -brief把叶子证书或根证书导出,导入到 Java 的信任库里:
keytool -import -alias ftps-server -keystore ftps.truststore -file server-ca.pem然后用 JVM 参数指定这个信任库,而不是在代码里写一个“信任所有人”的 TrustManager:
java -Djavax.net.ssl.trustStore=ftps.truststore -Djavax.net.ssl.trustStorePassword=changeit -jar app.jar很多网上教程会让你写一个空实现的TrustManager来绕过校验,短时间联调可以,但生产环境千万别这么干。宁可把全链路通一次之后再做一次安全加固。
5. 一份可以直接落地的修复代码与验证清单
5.1 兼容显式和隐式的写法
把整个排查过程收敛下来,我最后用在生产修复里的代码长这样,同时兼容隐式和显式两种场景,只是通过参数控制:
private FTPSClient buildFtpsClient(boolean implicit, int port, String host) throws Exception { SSLContext sslContext = SSLContext.getInstance("TLSv1.3"); sslContext.init(null, null, new SecureRandom()); FTPSClient ftps = new FTPSClient(implicit, sslContext); ftps.setEnabledProtocols(new String[]{"TLSv1.3", "TLSv1.2"}); ftps.setDefaultTimeout(10000); ftps.setConnectTimeout(10000); ftps.connect(host, port); // 登录之后、上传之前,务必执行这两条 ftps.execPBSZ(0); ftps.execPROT("P"); ftps.setFileType(FTPClient.BINARY_FILE_TYPE); return ftps; }如果对端只支持 TLSv1.2,那就把setEnabledProtocols换成new String[]{"TLSv1.2"}或者同时带 TLSv1.2/TLSv1.3。注意不要把SSLv3加进去,既不安全,现在服务端也基本不接受。
上传部分我建议单独封装:
try (InputStream input = new FileInputStream(file)) { boolean done = ftps.storeFile(remotePath, input); if (!done) { System.out.println("上传失败, reply code=" + ftps.getReplyCode()); } } finally { ftps.logout(); ftps.disconnect(); }storeFile返回 false 的时候,不要只打印一个 false,先把getReplyCode()和getReplyString()打出来。FTPS 的报错本来就信息少,reply code 是唯一能跟服务端日志对上号的线索。
5.2 验证完整握手的步骤与日志特征
修复上线之前,我整理了一份针对 FTPS 上传的验证清单,照着做一遍基本能排除 90% 的同类问题:
- 端口探测:
nc -vz host 990,先确认端口本身通。 - 隐式端口握手:
openssl s_client -connect host:990 -brief,确认服务端在 990 上确实提供 TLS。 - TLS 版本:记录 openssl 输出里的协议版本,和 Java 端
setEnabledProtocols对比。 - 证书链:
openssl s_client -showcerts导出的证书,确认客户端信任库里有没有对应 CA。 - 控制连接登录:看日志里是否有
230登录成功。 - PBSZ/PROT:确认
PBSZ=0和PROT P都返回成功。 - 数据连接:上传一个小文件,观察数据连接日志和
STOR命令的 reply code。
如果第 7 步失败,回到第 3 步,专门看数据连接是否复用了控制连接的 TLS 会话。Apache Commons Net 内部在创建数据连接时会自动做一次createCommandSocket,你可以在ftpsClient子类里覆盖_openDataConnection_,打印出实际建立的SSLSocket用的协议和会话 ID,跟控制连接比对。这个调试手段比瞎改参数要有用得多。
6. 这类问题的通用排误思维
6.1 从责任边界定位,而不是从代码找错
这次排错最深的感触是:遇到 SSLException 这种模糊异常,别一上来就改代码、加日志、反复重试。先把责任边界切清楚。
责任边界分四层:
- 第一层是 TCP:端口是否通,是否有中间设备拦截。
- 第二层是 TLS 协商:双方协议版本、密码套件、证书链是否匹配。
- 第三层是 FTPS 命令顺序:隐式/显式模式对不对,PBSZ/PROT 有没有执行。
- 第四层才是业务层:账号权限、路径是否存在、文件是否可读。
每一层用最小工具去验证,而不是全都在 Java 代码里测。网络层用nc和openssl,协议层用 FileZilla,最后才用 Java 代码复现。这样能快速把问题定位到某一层,而不是在代码里碰运气。
6.2 诊断一个TLS连接的标准动作
另一个收获是,我养成了“手边随时有 openssl”的习惯。以后不管遇到 SMTP、IMAP、FTPS 还是 HTTPS 的握手问题,我一般都会先做这三个动作:
先看出不出现证书:
openssl s_client -connect host:port -brief再看手写指定协议版本:
openssl s_client -connect host:port -tls1_2 -brief openssl s_client -connect host:port -tls1_3 -brief最后导出证书,验证信任链:
openssl s_client -connect host:port -showcerts -CAfile custom-ca.pem -verify_return_error这三个动作加起来不超过一分钟,但能把问题半径缩小到“协议版本不匹配”“证书不被信任”“服务端根本不打算握手”三种情况中的一种。
这次 FTPSClient 的报错让我把 TLS 握手的过程完整摸了一遍。遇到问题之后不要怕异常信息少,先确认基础链路,再分别抓客户端和服务端的日志,问题一定会透出来。如果你也是从 FTP 切到 FTPS,并且遇到了一模一样的握手终止错误,建议从显式隐式端口这个最简单的点查起,大概率会在 20 分钟内找到答案。