Java SSLSocket开发HTTPS服务TLS握手未完成页面加载超时问题
嵌入式HTTP/HTTPS服务器TLS握手卡住问题
背景
- 为嵌入式设备框架开发小型HTTP/HTTPS服务器,基于原生Java Socket实现,自行处理TLS/SSL逻辑
- 客户端握手流程实现代码如下:
if (secureIO) { // 握手成功后打印日志 ((SSLSocket) clientIO) .addHandshakeCompletedListener(new HandshakeCompletedListener() { @Override public void handshakeCompleted(HandshakeCompletedEvent eventIO) { logIO.debug(GuardianLog.Type.SUCCESS, "Handshake with Client " + clientIO.getInetAddress().getHostAddress() + " via Method " + eventIO + " was correct."); // 解锁流 lockIO[0] = false; } }); // ((SSLSocket) clientIO).setNeedClientAuth(true); ((SSLSocket) clientIO).setEnableSessionCreation(true); // 继承服务端的协议和加密套件配置 ((SSLSocket) clientIO) .setEnabledProtocols(((SSLServerSocket) tcpIO).getEnabledProtocols()); ((SSLSocket) clientIO) .setEnabledCipherSuites( ((SSLServerSocket) tcpIO).getEnabledCipherSuites()); // 启动握手 ((SSLSocket) clientIO).startHandshake(); }
故障现象
浏览器访问对应端口时页面一直加载,通过OPENSSL调试确认TLS握手未完成。
最初怀疑监听器干扰流读写,因此通过lockIO布尔值禁用输入输出流,等握手完成监听器触发后再解锁,服务端调试日志显示服务端已经发出TLS1.3握手报文:
javax.net.ssl|ALL|1C|Server-Socket|2022-07-08 21:51:57.696 CEST|SSLSessionImpl.java:250|Session initialized: Session(1657309917387|TLS_AES_256_GCM_SHA384) javax.net.ssl|FINE|1C|Server-Socket|2022-07-08 21:51:57.696 CEST|SSLSocketOutputRecord.java:241|WRITE: TLS13 handshake, length = 50 javax.net.ssl|FINE|1C|Server-Socket|2022-07-08 21:51:57.697 CEST|SSLCipher.java:2013|Plaintext before ENCRYPTION ( 0000: 04 00 00 2E 00 01 51 80 F9 1F 1E 31 01 01 00 20 ......Q....1... 0010: E5 5B CF E4 29 3B 0E 9F E4 12 D7 CD 8B 34 2C 22 .[..);.......4," 0020: 0B 14 45 B3 E9 94 CC 62 64 C3 06 E4 4F 72 82 D3 ..E....bd...Or.. 0030: 00 00 16 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0040: 00 00 00 ... ) javax.net.ssl|FINE|1C|Server-Socket|2022-07-08 21:51:57.697 CEST|SSLSocketOutputRecord.java:255|Raw write ( 0000: 17 03 03 00 53 6B D3 EE EF C5 DD D2 E3 3F DF 15 ....Sk.......?.. 0010: 13 87 A5 9E BB AC 0A 1F 1F 6A 77 50 B1 4D 51 39 .........jwP.MQ9 0020: 4D 0C 00 78 D1 4A D8 78 79 9A 79 7E AD EB 20 80 M..x.J.xy.y... . 0030: 91 C3 A5 14 33 FF 13 01 9B D6 37 7E 3A 7B 9F 7D ....3.....7.:... 0040: 03 E6 59 90 FF 9B E3 63 87 18 FD 84 37 C7 21 CA ..Y....c....7.!. 0050: A7 AC ED 25 C3 05 73 A8 ...%..s. )
流读取逻辑在Socket.accept()后初始化,实现如下:
private void read(InputStream streamIO, java.net.Socket clientIO) { poolIO.execute(new Runnable() { @Override public void run() { try { while (!shutdownIO && clientIO.isConnected() && !tcpIO.isClosed()) { // 根据当前可读取字节数创建缓冲区 byte[] bufferIO = new byte[streamIO.available()]; // 读取数据 int codeIO = streamIO.read(bufferIO); if (codeIO != 0 || bufferIO.length != 0) { System.out.println(codeIO); System.out.println(bufferIO.length); } // 处理数据 if (bufferIO.length != 0 && streamIO.available() == 0) { if (binaryIO) binary(new Socket(clientIO), bufferIO); if (stringIO) string(new Socket(clientIO), new String(bufferIO, charsetIO)); } if (eofIO && (codeIO == -1)) { break; } // 休眠100ms降低CPU占用 Thread.sleep(100); } streamIO.reset(); streamIO.close(); clientIO.close(); disconnect(new Socket(clientIO)); } catch (IOException | InterruptedException errorIO) { if (errorIO.getMessage().equalsIgnoreCase("Stream closed.") || errorIO.getMessage().equalsIgnoreCase("Connection reset")) { logIO.debug(GuardianLog.Type.INFO, "Client with IP " + clientIO.getInetAddress().getHostAddress() + " disconnected from Server with Port " + networkIO.getPort() + "."); disconnect(new Socket(clientIO)); } else if (errorIO.getMessage().equalsIgnoreCase("sleep interrupted")) { logIO.debug( GuardianLog.Type.WARNING, "Socket Thread interrupted.", errorIO); } else logIO.append( GuardianLog.Type.ERROR, "Socket throw an Error ", errorIO); } } }); }
问题根因
握手卡死由三个核心逻辑错误导致:
- 握手流程死锁:用
lockIO锁死流读写,等待握手完成回调触发才解锁,但TLS握手本身需要服务端读取客户端返回的握手报文才能推进到完成状态,锁死读操作会导致永远等不到握手完成事件。 - 流读取逻辑错误:
- 用
streamIO.available()返回值作为缓冲区长度是Java Socket编程的典型错误:available()仅返回当前可无阻塞读取的字节数,不代表流中后续没有数据。握手阶段客户端的响应报文可能还在传输中,此时available()返回0,创建长度为0的缓冲区根本读不到任何握手响应数据。 - 空缓冲区调用
read()不会读取任何有效数据,服务端发完ServerHello后一直等不到客户端的Finish报文,握手自然卡住。
- 用
- 轮询逻辑缺陷:每次循环固定sleep 100ms的轮询模式会导致数据读取延迟,TLS握手阶段的报文交互对时序敏感,延迟读取很容易导致握手超时、状态卡住。
代码中还有两个隐性bug:
- 直接调用
streamIO.reset()会抛出异常,Socket输入流不支持mark/reset操作,不需要也不能调用这个方法。 - 业务处理时反复
new Socket(clientIO)会创建大量冗余对象,完全没有必要,直接复用传入的clientIO实例即可。
修复方案
- 移除握手阶段的流锁逻辑,不要阻塞SSLSocket的底层读写:
startHandshake()调用后,SSLSocket会自动处理握手阶段的报文交互,不需要手动加锁拦截流操作。如果需要同步等待握手完成,可以在startHandshake()后直接调用((SSLSocket) clientIO).getSession(),该方法会阻塞到握手完成,不需要依赖HandshakeCompletedListener做流解锁。 - 重写流读取逻辑,放弃
available()判断和轮询sleep模式,直接用固定大小的缓冲区阻塞读取即可,参考实现:
private void read(InputStream streamIO, java.net.Socket clientIO) { poolIO.execute(() -> { // 嵌入式场景可根据内存大小调整为4KB/8KB,常规场景用16KB即可 byte[] bufferIO = new byte[16384]; try { int codeIO; while (!shutdownIO && clientIO.isConnected() && !clientIO.isClosed()) { // 阻塞读取,有数据返回,无数据等待,不会空转浪费CPU codeIO = streamIO.read(bufferIO); if (codeIO == -1) { // 读到EOF,连接断开 break; } // 复制实际读取的有效数据,避免缓冲区复用导致脏数据 byte[] realData = Arrays.copyOf(bufferIO, codeIO); if (binaryIO) { binary(clientIO, realData); } if (stringIO) { string(clientIO, new String(realData, charsetIO)); } } } catch (IOException errorIO) { String errMsg = errorIO.getMessage(); if ("Stream closed.".equalsIgnoreCase(errMsg) || "Connection reset".equalsIgnoreCase(errMsg)) { logIO.debug(GuardianLog.Type.INFO, "Client with IP " + clientIO.getInetAddress().getHostAddress() + " disconnected from Server with Port " + networkIO.getPort() + "."); } else { logIO.append(GuardianLog.Type.ERROR, "Socket throw an Error ", errorIO); } } finally { try { streamIO.close(); clientIO.close(); } catch (IOException e) { // 忽略关闭阶段的异常 } disconnect(clientIO); } }); }
内容的提问来源于stack exchange,提问作者Jan Heil
相关产品推荐
相关产品推荐

