FTP服务器搭建踩坑和工具类实现
FTP服务器搭建
这边不再介绍安装方法,可以自己GPT下,主要记录一些安装过程中踩到的坑。
vsftpd(Linux)
如何配置SSL/TLS隐式加密连接
- 生成公钥和密钥
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/vsftpd/vsftpd.pem -out /etc/vsftpd/vsftpd.pem
- 在配置文件中添加如下配置
# 启用SSL
ssl_enable=YES
# 使用隐式FTP over TLS
implicit_ssl=YES
# 指定证书文件路径
rsa_cert_file=/etc/vsftpd/vsftpd.pem
rsa_private_key_file=/etc/vsftpd/vsftpd.pem
# 允许匿名用户使用TLS
allow_anon_ssl=NO
# 强制使用TLS进行数据传输
force_local_data_ssl=YES
force_local_logins_ssl=YES
# 设置允许的最小TLS版本
ssl_tlsv1=YES
ssl_sslv2=NO
ssl_sslv3=NO
# 设置允许的加密套件
ssl_ciphers=HIGH
FileZilla Server(Windows)
SSL/TLS加密连接失败可能出现的原因
- 公钥和私钥的路径不要有中文,不然你安装的版本可能出现识别不到,导致一直连接失败!
工具类实现
为了实现FTP服务器上上传和下载,并且支持FTPS,编写了如下工具类。FTP连接主要使用hutool工具,FTPS连接使用Apache Common Net。故操作前需导入对应依赖。
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.11</version>
</dependency>
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.8.0</version>
</dependency>
- 工具类所需的登录参数DTO
@Data
@ApiModel(value = "FTP登录信息DTO")
public class FtpLoginDTO {
@ApiModelProperty(value = "FTP服务器IP地址")
private String host;
@ApiModelProperty(value = "FTP服务器端口")
private int port;
@ApiModelProperty(value = "FTP服务器用户名")
private String username;
@ApiModelProperty(value = "FTP服务器密码")
private String password;
@ApiModelProperty(value = "连接方式 NONE:不加密 EXPLICIT:SSL/TLS显式加密 IMPLICIT:SSL/TLS隐式加密")
private String encryptionMode;
@ApiModelProperty(value = "传输模式 ACTIVE:主动 PASSIVE:被动")
private String transmissionMode;
@ApiModelProperty(value = "最大重试次数")
private int maxRetries;
}
- 所需用到的常量类
public class FtpConstant {
/**
* 加密方式:不加密
*/
public static final String ENCRYPTION_MODE_NONE = "NONE";
/**
* 加密方式:SSL/TLS 显式加密
*/
public static final String ENCRYPTION_MODE_EXPLICIT = "EXPLICIT";
/**
* 加密方式:SSL/TLS 隐式加密
*/
public static final String ENCRYPTION_MODE_IMPLICIT = "IMPLICIT";
/**
* 传输模式:主动
*/
public static final String TRANSMISSION_MODE_ACTIVE = "ACTIVE";
/**
* 传输模式:被动
*/
public static final String TRANSMISSION_MODE_PASSIVE = "PASSIVE";
}
- FTPS工具类实现
package net.evecom.iaplatform.common.utils;
import cn.hutool.core.util.ObjectUtil;
import net.evecom.iaplatform.common.constants.FtpConstant;
import org.apache.commons.net.PrintCommandListener;
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPReply;
import org.apache.commons.net.ftp.FTPSClient;
import org.slf4j.Logger;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;
/**
* <p>
* <B>Description: Ftps工具</B>
* </P>
*
* @author Ryan Huang
* @version 1.0
*/
public class FtpsUtil {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(FtpsUtil.class);
private final String host;
private final int port;
private final String username;
private final String password;
/**
* 传输模式 ACTIVE:主动 PASSIVE:被动
*/
private final String transmissionMode;
private final FTPSClient ftpsClient;
private final int maxRetries;
/**
* 构造函数,用于初始化FTPS工具类,设置服务器凭证、重试次数和加密模式。
*
* @param host FTPS服务器主机名。
* @param port FTPS服务器端口。
* @param username 认证用户名。
* @param password 认证密码。
* @param maxRetries 操作的最大重试次数。
*/
public FtpsUtil(String host, int port, String username, String password, String transmissionMode, String encryptionMode, int maxRetries) {
this.host = host;
this.port = port;
this.username = username;
this.password = password;
this.transmissionMode = transmissionMode;
this.maxRetries = maxRetries;
try {
if(ObjectUtil.equals(encryptionMode, FtpConstant.ENCRYPTION_MODE_IMPLICIT)) {
ftpsClient = new FTPSClient("TLS", true);
}else if(ObjectUtil.equals(encryptionMode, FtpConstant.ENCRYPTION_MODE_EXPLICIT)){
ftpsClient = new FTPSClient("TLS", false);
}else{
throw new RuntimeException("加密方式不正确");
}
ftpsClient.addProtocolCommandListener(new PrintCommandListener(new PrintWriter(System.out)));
}catch (Exception e){
logger.error("FTP连接失败:", e);
throw new RuntimeException("FTP连接失败:" + e.getMessage());
}
}
/**
* 初始化与FTPS服务器的连接,并在连接失败时实现重试机制。
*
* @return 如果连接成功返回true,否则返回false。
*/
public boolean connect() {
int retries = 0;
while (retries < maxRetries) {
try {
ftpsClient.connect(host, port);
int reply = ftpsClient.getReplyCode();
if (!FTPReply.isPositiveCompletion(reply)) {
ftpsClient.disconnect();
logger.error("FTP服务器拒绝连接。");
retries++;
continue;
}
if (ftpsClient.login(username, password)) {
ftpsClient.execPBSZ(0);
ftpsClient.execPROT("P");
ftpsClient.setFileType(FTP.BINARY_FILE_TYPE);
//传输模式
if(ObjectUtil.equals(transmissionMode, FtpConstant.TRANSMISSION_MODE_ACTIVE)){
ftpsClient.enterLocalActiveMode();
}else if(ObjectUtil.equals(transmissionMode, FtpConstant.TRANSMISSION_MODE_PASSIVE)) {
ftpsClient.enterLocalPassiveMode();
}
logger.info("已连接到FTPS服务器。");
return true;
} else {
ftpsClient.logout();
logger.error("FTPS登录失败。");
retries++;
}
} catch (IOException ex) {
logger.error("连接尝试失败: " + ex.getMessage());
retries++;
}
try {
Thread.sleep(2000); // 重试前等待2秒
} catch (InterruptedException e) {
logger.error("线程中断", e);
}
}
logger.error("FTP连接失败。");
throw new RuntimeException("FTP连接失败。");
}
/**
* 从FTPS服务器下载文件到本地文件系统,并在下载失败时实现重试机制。
*
* @param remoteFilePath 服务器上的文件路径。
* @param localFilePath 本地文件系统的目标路径。
* @return 如果文件下载成功返回true,否则返回false。
*/
public boolean downloadFile(String remoteFilePath, String localFilePath) {
int retries = 0;
while (retries < maxRetries) {
if (!ftpsClient.isConnected()) {
if (!connect()) {
retries++;
continue;
}
}
try (OutputStream outputStream = Files.newOutputStream(Paths.get(localFilePath))) {
boolean success = ftpsClient.retrieveFile(remoteFilePath, outputStream);
if (success) {
logger.info("文件【{}】下载成功。", remoteFilePath);
return true;
} else {
retries++;
}
} catch (IOException ex) {
logger.error("下载【{}】文件时出错: {}", remoteFilePath, ex.getMessage());
retries++;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
logger.error("线程中断", e);
}
}
return false;
}
/**
* 从本地文件系统上传文件到FTPS服务器,并在上传失败时实现重试机制。
*
* @param localFilePath 本地文件的路径。
* @param remoteFilePath 服务器上的目标路径。
* @return 如果文件上传成功返回true,否则返回false。
*/
public boolean uploadFile(String localFilePath, String remoteFilePath) {
int retries = 0;
while (retries < maxRetries) {
if (!ftpsClient.isConnected()) {
if (!connect()) {
retries++;
continue;
}
}
try (InputStream inputStream = Files.newInputStream(Paths.get(localFilePath))) {
boolean success = ftpsClient.storeFile(remoteFilePath, inputStream);
if (success) {
logger.info("文件【{}】上传成功。", remoteFilePath);
return true;
} else {
logger.error("文件【{}】上传失败。", remoteFilePath);
retries++;
}
} catch (IOException ex) {
logger.error("上传【{}】文件时出错: {}", remoteFilePath, ex.getMessage());
retries++;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
logger.error("线程中断", e);
}
}
return false;
}
/**
* 判断文件或目录是否存在
*
* @param path 文件或目录路径
* @return true:存在,false:不存在
*/
public boolean isExist(String path) {
try {
// 切换到指定目录
boolean result = ftpsClient.changeWorkingDirectory(path);
if (result) {
return true; // 目录存在
}
// 尝试获取文件信息
FTPFile[] files = ftpsClient.listFiles(path);
if (files != null && files.length > 0) {
return true; // 文件存在
}
} catch (Exception e) {
// 发生异常,文件或目录不存在
return false;
}
return false;
}
/**
* 创建多级目录
*
* @param directory 目录路径
*/
public void mkdirs(String directory) {
String[] dirs = directory.split("/");
String currentDir = "";
for (String dir : dirs) {
currentDir += dir + "/";
if (!isExist(currentDir)) {
try {
if (!ftpsClient.makeDirectory(currentDir)) {
break;
}
} catch (IOException e) {
logger.error("创建目录 " + currentDir + " 失败:" + e.getMessage());
}
}
}
}
/**
* 断开与FTPS服务器的连接。
*/
public void disconnect() {
if (ftpsClient.isConnected()) {
try {
ftpsClient.logout();
ftpsClient.disconnect();
} catch (IOException ex) {
logger.error("断开连接失败:" + ex.getMessage(), ex);
}
}
}
}
- FTP工具类实现
package net.evecom.iaplatform.common.utils;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.ftp.Ftp;
import cn.hutool.extra.ftp.FtpMode;
import net.evecom.iaplatform.api.model.dto.FtpLoginDTO;
import net.evecom.iaplatform.common.constants.FtpConstant;
import org.slf4j.Logger;
import java.io.*;
import java.nio.file.Files;
/**
* <p>
* <B>Description: FTP 工具</B>
* </P>
*
* @author Ryan Huang
* @version 1.0
*/
public class FtpUtil {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(FtpUtil.class);
private final String host;
private final int port;
private final String username;
private final String password;
/**
* 连接方式 NONE:不加密 EXPLICIT:SSL/TLS显式加密 IMPLICIT:SSL/TLS隐式加密
*/
private final String encryptionMode;
/**
* 传输模式 ACTIVE:主动 PASSIVE:被动
*/
private final String transmissionMode;
/**
* 重连最大次数
*/
private final int maxRetries;
/**
* FTP 客户端
*/
private Ftp ftpClient;
/**
* FTPS工具
*/
private FtpsUtil ftpsUtil;
/**
* 构造函数,用于初始化FTP
*
* @param info 登录信息
*/
public FtpUtil(FtpLoginDTO info) {
this.host = info.getHost();
this.port = info.getPort();
this.username = info.getUsername();
this.password = info.getPassword();
this.encryptionMode = info.getEncryptionMode();
this.transmissionMode = info.getTransmissionMode();
this.maxRetries = info.getMaxRetries();
try {
initializeFtpsClient();
}catch (Exception e) {
logger.error("FTP连接失败:" + e.getMessage(), e);
throw new RuntimeException("FTP连接失败:" + e.getMessage());
}
}
/**
* 判断是否是FTPS
*/
public boolean isFtps(){
return ObjectUtil.equals(encryptionMode, FtpConstant.ENCRYPTION_MODE_IMPLICIT) || ObjectUtil.equals(encryptionMode, FtpConstant.ENCRYPTION_MODE_EXPLICIT);
}
/**
* 初始化FTP客户端
*/
private void initializeFtpsClient() {
//SSL/TLS加密
if(isFtps()){
ftpsUtil = new FtpsUtil(host, port, username, password, transmissionMode, encryptionMode, maxRetries);
ftpsUtil.connect();
}
//不加密
else {
ftpClient = new Ftp(host, port, username, password);
if (ObjectUtil.equals(transmissionMode, FtpConstant.TRANSMISSION_MODE_ACTIVE)) {
ftpClient.setMode(FtpMode.Active);
} else if (ObjectUtil.equals(transmissionMode, FtpConstant.TRANSMISSION_MODE_PASSIVE)) {
ftpClient.setMode(FtpMode.Passive);
}
}
}
/**
* 从FTPS服务器下载文件并返回一个InputStream,并在下载失败时实现重试机制。
*
* @param remoteFilePath 服务器上的文件路径。
* @return 下载文件的InputStream,如果下载失败返回null。
*/
public InputStream downloadFile(String remoteFilePath) {
String localFilePath = "/data/" + remoteFilePath;
File localFile = new File(localFilePath);
//目录不存在则创建
File parentFile = localFile.getParentFile();
if (!parentFile.exists()) {
parentFile.mkdirs();
}
if(isFtps()) {
boolean result = ftpsUtil.downloadFile(remoteFilePath, localFilePath);
if(!result){
throw new RuntimeException("文件【" + remoteFilePath + "】下载失败。");
}
}else {
ftpClient.download(remoteFilePath, localFile);
}
try {
return Files.newInputStream(localFile.toPath());
} catch (IOException e) {
throw new RuntimeException("文件【" + remoteFilePath + "】下载失败:" + e.getMessage());
}
}
/**
* 从本地文件系统上传文件到FTPS服务器,并在上传失败时实现重试机制。
*
* @param localFilePath 本地文件的路径。
* @param remoteFilePath 服务器上的目标路径。
* @return 如果文件上传成功返回true,否则返回false。
*/
public boolean uploadFile(String localFilePath, String remoteFilePath) {
//根据文件路径获取目录和文件名
String fileName = remoteFilePath.substring(remoteFilePath.lastIndexOf("/") + 1);
String remotePath = remoteFilePath.substring(0, remoteFilePath.lastIndexOf("/"));
if(isFtps()) {
ftpsUtil.mkdirs(remotePath);
return ftpsUtil.uploadFile(localFilePath, remoteFilePath);
}else {
//判断ftpClient目标目录是否存在不存在则创建
if (!ftpClient.exist(remotePath)) {
ftpClient.mkDirs(remotePath);
}
return ftpClient.upload(remotePath, fileName, new File(localFilePath));
}
}
/**
* 断开与FTPS服务器的连接。
*/
public void disconnect() {
try {
if(isFtps()) {
ftpsUtil.disconnect();
}else {
ftpClient.close();
}
}catch (Exception e){
logger.error("断开连接失败:"+e.getMessage(),e);
}
}
}
其他
另外写了一篇关于解决425 Unable to build data connection: TLS session of data connection not resumed.
的随笔,有需要可以看看:https://www.cnblogs.com/IamHzc/p/18516621