基于Apache MINA SSHD配置及应用

发布于:2025-07-08 ⋅ 阅读:(9) ⋅ 点赞:(0)

Apache MINA SSHD 是一个基于 Java 的 SSH 服务器和客户端实现,它是 Apache MINA 项目的一部分,提供了完整的 SSH 协议支持。

主要特性

  1. SSH 协议支持

    • 支持 SSH2 协议

    • 兼容大多数 SSH 客户端

    • 支持多种加密算法和密钥交换方法

  2. 服务器功能

    • 可嵌入的 SSH 服务器

    • 支持密码认证和公钥认证

    • 支持端口转发

    • 可自定义的 shell 和命令执行

  3. 客户端功能

    • 完整的 SSH 客户端实现

    • 支持交互式和非交互式会话

    • 支持 SCP 和 SFTP 文件传输

  4. 具体配置

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