背景
最近要做一个通过FTPS上传文件到FTP服务端的功能,FTP服务端是搭建在Linux系统上的vsftpd。目前项目是有通过FTP传输文件的功能的,但没有走SSL/TLS加密协议,也就是不支持FTPS。
本以为是个比较简单的修改,因为依赖的commons-io库,里面有现成的FTPSClient类。但实际开发调试过程中遇到一个比较棘手的问题:使用FTPClient连接FTP和登录都没有问题,但是上传文件失败,FTP服务端的报错是
425 Unable to build data connection: TLS session of data connection not resumed。(我使用的FileZilla Server作为服务端)
或者 522 SSL connection failed; session reuse required: see require_ssl_reuse option in vsftpd.conf man page。(这个是vsftpd的报错)
经过研究这是由于FTP服务端开启SSL/TLS加密后,默认要求所有SSL数据连接都需要显示SSL会话重用,如果把这个设置关掉,就可以正常传输。(我用的FileZilla Server 1.11.1版本,并没有控制这个功能开启/关闭的设置项。后来换了FileZilla Server中文版,对应的设置项是“在SSL/TLS模式强制使用“PORT P”加密数据通道传输文件”。vsftpd在配置文件中用require_ssl_reuse控制。)
“显示SSL会话重用”的大概的意思就连接和上传文件都得用一个SSL会话,而目前的FTPSClient在上传文件的时候根据FTP服务端新分配的IP和PORT重新创建了一个SSL会话,导致了报错。感兴趣的可以自行了解。
目前项目依赖的commons-io库版本是2.6,FTPSClient不支持ssl通道重用。因为我们的项目sdk、jdk(1.8)版本比较老,暂不考虑升级版本的情况下需要解决此问题(我没研究commons-io新版本是否仍有此问题)。
网上可以查到通过重写FTPSClient类来解决此问题的方案,大概逻辑是通过反射的方法将连接用的IP和PORT赋值给FTPSClient的一个缓存,这样FTPSClient在上传文件的时候就不会新建SSL会话了。但是在我这里行不通,原因可能有很多,比如sdk版本等,结果就是需要反射获取的字段在我的源码里就不存在。
但知道方案大概逻辑后,就可以自己再加工一下了。我先附上网上查到的解决方案(方案一),后面是我自己加工的方案(方案二),也是最终解决了我的问题的方案。
方案一:
public class CustomFTPSClient extends FTPSClient {
private static final String TAG = "CustomFTPSClient";
public CustomFTPSClient(boolean isImplicit, SSLContext sslContext) {
super(isImplicit, sslContext);
}
@Override
protected void _prepareDataSocket_(final Socket socket) throws IOException {
if (socket instanceof SSLSocket) {
// Control socket is SSL
final SSLSession session = ((SSLSocket) _socket_).getSession();
final SSLSessionContext context = session.getSessionContext();
// context.setSessionCacheSize(preferences.getInteger("ftp.ssl.session.cache.size"));
try {
final Field sessionHostPortCache = context.getClass().getDeclaredField("sessionHostPortCache");
sessionHostPortCache.setAccessible(true);
final Object cache = sessionHostPortCache.get(context);
final Method method = cache.getClass().getDeclaredMethod("put", Object.class, Object.class);
method.setAccessible(true);
final String key = String.format("%s:%s", socket.getInetAddress().getHostName(), String.valueOf(socket.getPort())).toLowerCase(Locale.ROOT);
method.invoke(cache, key, session);
} catch (NoSuchFieldException e) {
e.printStackTrace();
// Not running in expected JRE
System.out.println("No field sessionHostPortCache in SSLSessionContext");
} catch (Exception e) {
// Not running in expected JRE
e.printStackTrace();
}
}
}
}
方案二:
public class CustomFTPSClient extends FTPSClient {
private static final String TAG = "CustomFTPSClient";
public CustomFTPSClient(boolean isImplicit, SSLContext sslContext) {
super(isImplicit, sslContext);
}
@Override
protected void _prepareDataSocket_(final Socket socket) throws IOException {
if (socket instanceof SSLSocket) {
LogUtil.v(TAG, "Start cached session");
// com.android.org.conscrypt.OpenSSLSessionImpl
final SSLSession session = ((SSLSocket) _socket_).getSession();
// com.android.org.conscrypt.ClientSessionContext
final SSLSessionContext sessionContext = session.getSessionContext();
try {
// SSLSessionContext中有一个sessionsByHostAndPort字段,是一个Map类型
// sessionsByHostAndPort的key值是ClientSessionContext$HostAndPort类型,value是SSLSession类型
// HostAndPort中有host和port字段(这两个字段是服务端的IP和服务端监听FTP服务的端口号)
// 现在需要做的是在sessionsByHostAndPort对象中,将服务端的IP和给客户端分配的端口号,使用上述相同的value(SSLSession)
// 1. 获取 sessionsByHostAndPort 字段
final Field sessionsByHostAndPortField = sessionContext.getClass().getDeclaredField("sessionsByHostAndPort");
sessionsByHostAndPortField.setAccessible(true);
final Map<?, ?> cache = (Map<?, ?>) sessionsByHostAndPortField.get(sessionContext);
// 2. 获取 HostAndPort 内部类
Class<?> hostAndPortClass = null;
for (Class<?> innerClass : sessionContext.getClass().getDeclaredClasses()) {
if (innerClass.getSimpleName().equals("HostAndPort")) {
hostAndPortClass = innerClass;
break;
}
}
if (hostAndPortClass == null) {
LogUtil.e(TAG, "HostAndPort inner class not found");
return;
}
// 3. 查找或创建合适的 HostAndPort 键
Object targetKey = null;
// 服务端的IP
String targetHost = getPassiveHost();
// 服务端给客户端随机分配的端口号
int targetPort = getPassivePort();
// 如果没找到匹配的键,尝试创建新键
if (targetKey == null) {
try {
Constructor<?> constructor = hostAndPortClass.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);
targetKey = constructor.newInstance(targetHost, targetPort);
} catch (Exception e) {
LogUtil.cat(e);
return;
}
}
// 4. 将会话放入缓存
final Method putMethod = cache.getClass().getDeclaredMethod("put", Object.class, Object.class);
putMethod.setAccessible(true);
putMethod.invoke(cache, targetKey, session);
LogUtil.v(TAG, "Successfully cached session for " + targetHost + ":" + targetPort);
} catch (Exception e) {
LogUtil.cat(e);
}
}
}
}
再附上创建FTPSClient实例的代码:
//信任所有证书的方式
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return null;
}
public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
}
}
};
SSLContext sc = SSLContext.getInstance("TLSv1.2");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
CustomFTPSClient ftpsClient = new CustomFTPSClient(false, sc);
还有一点需要注意的是,成功登录FTP后,需要设置以下参数:
// 设置SSL/TLS参数
ftpsClient.execPBSZ(0); // 必须设置保护缓冲区大小
ftpsClient.execPROT("P"); // 设置数据通道保护级别为私有
其他就跟FTPClient一样了,本文章不做介绍。
关于FTP服务端的几个配置:
Protocol:Require explicit FTP over TLS
Minimum allowed TLS version: v1.2
TLS credentials: Use a self-signed X.509 certificate
1082

被折叠的 条评论
为什么被折叠?



