Apache MINA SSHD 是一个基于 Java 的 SSH 服务器和客户端实现,它是 Apache MINA 项目的一部分,提供了完整的 SSH 协议支持。
主要特性
SSH 协议支持:
支持 SSH2 协议
兼容大多数 SSH 客户端
支持多种加密算法和密钥交换方法
服务器功能:
可嵌入的 SSH 服务器
支持密码认证和公钥认证
支持端口转发
可自定义的 shell 和命令执行
客户端功能:
完整的 SSH 客户端实现
支持交互式和非交互式会话
支持 SCP 和 SFTP 文件传输
具体配置
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.sshd.client.SshClient;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.sftp.client.SftpClient;
import org.apache.sshd.sftp.client.SftpClientFactory;
import org.apache.sshd.sftp.common.SftpConstants;
import org.springframework.lang.NonNull;
import java.io.ByteArrayOutputStream;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* SshClient服务类
*
* @author gengzhy
* @since 2025/5/29 16:57
*/
@Slf4j
public class SshClientService {
private final SshClient sshClient;
private final SftpProperties properties;
public SshClientService(SshClient sshClient, SftpProperties properties) {
this.sshClient = sshClient;
this.properties = properties;
}
/**
* 读取文件
*
* @param filePath - 文件路径
* @return - Bytes
*/
public byte[] readOnce(@NonNull String filePath) throws IOException {
return executeInternal(session -> {
try (SftpClient sftpClient = sftpClient(session)) {
SftpClient.Attributes stat = beforeReadCheck(sftpClient, filePath);
try (InputStream in = new BufferedInputStream(sftpClient.read(filePath))) {
ByteArrayOutputStream out = new ByteArrayOutputStream((int) stat.getSize());
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer, 0, buffer.length)) != -1) {
out.write(buffer, 0, read);
}
return out.toByteArray();
}
} catch (IOException e) {
Throwable ex = getRootCause(e);
log.error("sftp 读取文件异常: {}", ex.getMessage(), ex);
throw new RuntimeException(ex.getMessage(), ex);
}
});
}
/**
* 读取文件
*
* @param filePath - 文件路径
* @return - Bytes
*/
public List<String> readLines(@NonNull String filePath) throws IOException {
return readLines(filePath, null);
}
/**
* 读取文件
*
* @param filePath - 文件路径
* @param lineConsumer - 读取的数据行消费者
* @return - Bytes
*/
public List<String> readLines(@NonNull String filePath, Consumer<String> lineConsumer) throws IOException {
return executeInternal(session -> {
try (SftpClient sftpClient = sftpClient(session)) {
beforeReadCheck(sftpClient, filePath);
try (BufferedReader br = new BufferedReader(new InputStreamReader(sftpClient.read(filePath), StandardCharsets.UTF_8))) {
List<String> data = new ArrayList<>();
String line;
while ((line = br.readLine()) != null) {
data.add(line);
if (lineConsumer != null) {
lineConsumer.accept(line);
}
}
return data;
}
} catch (IOException e) {
Throwable ex = getRootCause(e);
log.error("sftp 读取文件异常: {}", ex.getMessage(), ex);
throw new RuntimeException(ex.getMessage(), ex);
}
});
}
/**
* 创建文件
*
* @param filePath - 文件路径
*/
public void writeOnce(@NonNull String filePath, byte[] data) throws IOException {
executeInternal(session -> {
try (SftpClient sftpClient = sftpClient(session)) {
mkDirsIfNotExists(sftpClient, getFileParentPath(filePath));
try (SftpClient.CloseableHandle handle = sftpClient.open(filePath, SftpClient.OpenMode.Create, SftpClient.OpenMode.Write, SftpClient.OpenMode.Read)) {
sftpClient.write(handle, 0L, data);
}
return null;
} catch (IOException e) {
Throwable ex = getRootCause(e);
log.error("sftp 创建文件异常: {}", ex.getMessage(), ex);
throw new RuntimeException(ex.getMessage(), ex);
}
});
}
/**
* 递归创建目录(等效于 mkdir -p)
*
* @param sftpClient SFTP 客户端
* @param dirPath 目标路径(如 "/a/b/c")
*/
public void mkDirsIfNotExists(SftpClient sftpClient, String dirPath) throws IOException {
if (dirPath == null) {
return;
}
String[] parts = dirPath.split("/");
StringBuilder currentPath = new StringBuilder();
for (String part : parts) {
if (part.isEmpty()) {
continue;
}
currentPath.append("/").append(part);
String path = currentPath.toString();
if (stat(sftpClient, path) == null) {
sftpClient.mkdir(path);
}
}
}
/**
* 提供一个便于扩展的内容部执行方法,主要是基于{@link ClientSession}对象的一些列操作
* <p>
* 如:
* 创建sftp客户端对象:{@link SftpClientFactory#instance()#createSftpClient(ClientSession)}
* 创建shell执行命令对象:{@link ClientSession#createExecChannel(String)}}
*
* @param execCall - 基于{@link ClientSession}对象的回调
* @param <T> - 返回数据类型
* @return - obj
*/
public <T> T executeInternal(Function<ClientSession, T> execCall) throws IOException {
try (ClientSession session = session()) {
return execCall != null ? execCall.apply(session) : null;
} catch (IOException e) {
log.error("创建ssh会话异常·: {}", e.getMessage(), e);
throw new IOException("创建ssh会话异常: " + e.getMessage(), e);
}
}
private SftpClient.Attributes beforeReadCheck(SftpClient sftpClient, String filePath) {
SftpClient.Attributes stat = stat(sftpClient, filePath);
if (stat == null) {
throw new RuntimeException("文件不存在【{" + filePath + "}】");
}
Asserts.isTrue(stat.isRegularFile(), "不支持的文件类型:" + FileType.getByActualType(stat.getType()));
Asserts.isTrue(stat.getSize() <= Integer.MAX_VALUE, "文件过大");
return stat;
}
private String getFileParentPath(String filePath) {
String path = filePath.replaceAll("[\\\\/]+", "/");
int index = path.lastIndexOf('/');
return index == -1 ? null : path.substring(0, index);
}
/**
* 获取文件信息,不存在返回null
*
* @param sftpClient - sftp客户端
* @param filePath - 文件路径
*/
private SftpClient.Attributes stat(SftpClient sftpClient, String filePath) {
try {
return sftpClient.stat(filePath);
} catch (IOException e) {
return null;
}
}
private SftpClient sftpClient(ClientSession session) throws IOException {
return SftpClientFactory.instance().createSftpClient(session);
}
private ClientSession session() throws IOException {
synchronized (sshClient) {
if (!sshClient.isStarted()) {
sshClient.start();
}
}
ClientSession session = sshClient.connect(properties.getUsername(), properties.getHost(), properties.getPort())
.verify(properties.getConnectTimeout())
.getSession();
session.addPasswordIdentity(properties.getPassword());
session.auth().verify(properties.getAuthTimeout());
return session;
}
private Throwable getRootCause(Throwable ex) {
while (ex.getCause() != null) {
ex = ex.getCause();
}
return ex;
}
@Getter
@AllArgsConstructor
private enum FileType {
file(SftpConstants.SSH_FILEXFER_TYPE_REGULAR),
dir(SftpConstants.SSH_FILEXFER_TYPE_DIRECTORY),
symlink(SftpConstants.SSH_FILEXFER_TYPE_SYMLINK),
special_file(SftpConstants.SSH_FILEXFER_TYPE_SPECIAL),
SOCKET(SftpConstants.SSH_FILEXFER_TYPE_SOCKET),
unknown(SftpConstants.SSH_FILEXFER_TYPE_UNKNOWN),
;
private final int actualType;
public static FileType getByActualType(int actualType) {
return Arrays.stream(FileType.values()).filter(item -> item.actualType == actualType)
.findFirst()
.orElseThrow(() -> new RuntimeException("Unknown file type: " + actualType));
}
}
}
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@Getter
@Setter
@ConfigurationProperties(prefix = "sftp")
public class SftpProperties {
/**
* 主机IP
*/
private String host = "127.0.0.1";
/**
* 主机端口,默认22
*/
private int port = 22;
/**
* 用户名
*/
private String username = "";
/**
* 登录密码
*/
private String password = "";
/**
* 连接超时(ms),默认5000ms
*/
private Duration connectTimeout = Duration.ofMillis(5000);
/**
* 认证超时(ms),默认10000ms
*/
private Duration authTimeout = Duration.ofMillis(10000);
}
import org.apache.sshd.client.SshClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ConditionalOnClass(SshClient.class)
@Configuration
@EnableConfigurationProperties(SftpProperties.class)
public class SshClientConfiguration {
@Bean
SshClientService sshClientService(SftpProperties properties) {
return new SshClientService(SshClient.setUpDefaultClient(), properties);
}
}
sftp:
host: 127.0.0.1
port: 22
username: demo
password: pwd
connect-timeout: 5s
auth-timeout: 5s