
前阵子接手一个运维平台项目需要在 Java 后台内置一个 SSH 服务让用户能通过浏览器远程连接内网主机还要支持批量下发命令。我第一反应是调用系统自带的 OpenSSH但很快就被实际情况打了脸客户的机器是 Windows 和 Linux 混合环境不能假设每台都装了 SSH 服务端更不能要求运维开放额外端口做转发。后来换了一个思路直接在 Java 进程里嵌入式跑一个 SSH Server最终选型定在了 Apache MINA SSHD。这里把我的配置和应用实践经验完整梳理出来适合 Java 开发者、中间件开发者和做运维工具的团队参考。Apache MINA SSHD 是纯 Java 实现的 SSH 协议库既支持服务端也支持客户端SFTP、SCP、端口转发、公钥认证这些都覆盖得比较全。这篇文章不会只贴一段启动代码就完事我会把从选型、最小可运行服务、内部机制、认证、批量远程执行、SFTP 文件传输到生产环境排障的完整链路都讲清楚并附上我实际踩过的坑。1. 为什么选 Apache MINA SSHD对比之后我放弃了 JSch 和系统 OpenSSH1.1 需求场景最开始的需求听上去很简单Java 应用对外提供 Web 页面用户在页面上提交一条命令后台找一个目标机器执行并返回结果。传统做法一般是后台去调用ssh客户端进程比如ssh userhost df -h然后解析标准输出。这样实现快但后续麻烦非常多。我遇到的实际问题有三个一是目标机器不一定是 Linux也可能是 WindowsOpenSSH for Windows 的可用性和配置方式参差不齐二是依赖外部进程命令执行现场不好管控遇到超时、杀掉进程、环境变量污染都是麻烦事三是安全审计、会话录制、动态授权这些需求很难在壳外面做干净。于是我开始找能在 JVM 内完成 SSH 通信的方案。1.2 三个候选方案的真实对比当时摆在面前的候选主要是JSch、Apache MINA SSHD、直接封装系统 OpenSSH。JSch 是老牌 Java SSH 库很多项目还在用。它的客户端功能比较成熟SFTP、端口转发都能做。但它的服务端能力基本是半残状态如果要自己实现一个嵌入式 SSH ServerJSch 需要你从协议底层补太多东西。而且项目维护节奏近两年变慢新算法支持比较滞后。系统 OpenSSH 的好处是稳定、兼容性好但坏处也很明显它不是 Java 库部署环境必须预装Windows 下的行为不一致想往里面塞自定义业务逻辑只能靠第三方工具或 LD_PRELOAD 之类的黑科技。Apache MINA SSHD 是 Apache 顶级项目孵化的 SSH 协议库基于 Java NIO 实现自带服务端和客户端。它把所有协议细节封装成框架开箱即用同时扩展性强认证、命令工厂、文件系统虚拟化、端口转发全都有 API 可以替换。最终我选它核心原因是既可以当服务端嵌入应用又可以当客户端做连接管理两边逻辑还能共用一个协议栈。1.3 Apache MINA SSHD 适合谁根据我的实际体验下面几类场景特别适合用 Apache MINA SSHD中间件开发者需要给应用内置 SSH 管理接口比如监控、诊断、动态配置。运维平台开发需要实现 Web 化的命令执行、批量部署、文件分发。内网穿透工具需要基于 SSH 协议做端口转发和反向隧道。教学和学习 SSH 协议直接读源码比看 RFC 文档舒服得多。如果只是单纯 Java 代码连 SSH 执行一两条命令JSch 也够用但一旦涉及“服务端 自定义认证 高并发连接管理”MINA SSHD 是更稳的选择。2. 第一个能连上的 SSH 服务器依赖配置与最小启动代码2.1 引入依赖Maven 项目引入两个依赖dependency groupIdorg.apache.sshd/groupId artifactIdsshd-core/artifactId version2.12.0/version /dependency dependency groupIdorg.apache.sshd/groupId artifactIdsshd-sftp/artifactId version2.12.0/version /dependencyGradle 同理。sshd-core是必需的核心包sshd-sftp是为了启用 SFTP 子系统。实际使用中可能还会用到sshd-scp但基础和文件传输有这两个就够了。版本我习惯用当前最新稳定版比如现在推的 2.12.x。版本差异会导致 API 有轻微变化但这个库整体风格比较稳定。2.2 最小服务器代码一个能通过 SFTP 客户端连接的嵌入式 SSH Server核心代码其实非常少import org.apache.sshd.server.SshServer; import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; import org.apache.sshd.sftp.server.SftpSubsystemFactory; import java.nio.file.Paths; import java.util.Collections; public class MinimalSshServer { public static void main(String[] args) throws Exception { SshServer sshd SshServer.setUpDefaultServer(); // 监听端口生产环境不要用 22避免和系统 SSH 冲突 sshd.setPort(2222); // 主机密钥用于身份证明必须持久化保存 sshd.setKeyPairProvider( new SimpleGeneratorHostKeyProvider(Paths.get(hostkey.ser))); // 密码认证这里仅做示例不要在生产用硬编码 sshd.setPasswordAuthenticator((username, password, session) - admin.equals(username) admin123.equals(password)); // 启用 SFTP 子系统 sshd.setSubsystemFactories( Collections.singletonList(new SftpSubsystemFactory())); sshd.start(); System.out.println(SSH server started on port sshd.getPort()); // 阻塞住等待连接 Thread.currentThread().join(); } }这段代码里最容易被忽略的是setKeyPairProvider。如果没有设置主机密钥服务端无法完成密钥交换客户端会直接报错。SimpleGeneratorHostKeyProvider会在第一次启动时生成密钥对并保存到指定文件第二次启动直接读取这样客户端known_hosts里的指纹就不会变。2.3 用 SFTP 客户端验证服务启动这段代码后我习惯先用 GUI 客户端做一次冒烟测试比如 FileZilla 或者 WinSCP选择 SFTP 协议主机填localhost端口填2222用户名和密码填上面代码里的 admin/admin123。如果能看到目录列表说明 TCP 层、密钥交换、认证层、SFTP 子系统全部通了。这时候你直接执行ssh -p 2222 adminlocalhost可能会报错或者什么都做不了因为没有配置 ShellFactory 和 CommandFactory。这属于预期行为不是 bug。MINA SSHD 把“SSH 能连上”和“能执行命令”拆成了两件事权限控制也更清晰。下一章我就讲怎么把这两件事补上。3. 弄懂 Session、Channel 和 Factory你才能写出可维护的服务器3.1 一次 SSH 连接经历了什么SSH 协议从下往上大致分三层传输层负责密钥交换、身份确认和加密用户认证层负责验证用户名和密码/公钥连接层负责在一个加密连接里建立多个 channel每个 channel 承载一次具体功能比如执行命令、打开 Shell、建立 SFTP 会话。用业内的话说一次 SSH 连接就是一个 Session一个 Session 里可以有多个 Channel。让我举个例子帮你理解你通过 SSH 客户端连着服务器同时在两个终端标签页里执行命令这两个终端标签页本质上是同一个 Session 里的两个 Channel。MINA SSHD 将这个过程映射成了非常清晰的 API。SshServer接受网络连接每个连接对应一个ServerSession认证通过后客户端要求开启 channel框架会根据 channel 的类型分发到对应的 Factory。你写的业务代码主要就围绕这些 Factory 展开。3.2 Command 与 Shell两种请求类型的差异客户端在 session channel 上可以发起几种不同的请求最常见的是exec和shell。exec是一次性命令调用。比如客户端执行ssh host df -h服务端收到一个execrequest命令内容就是df -h服务端执行完后输出结果并退出channel 关闭。这种模式非常适合自动化脚本和批量执行。shell则是交互式会话。客户端直接执行ssh host服务器需要给客户端提供一个虚拟终端客户端输入一行服务端返回一行全程保持连接。这适合 Web 网页里的在线终端。两者对应的服务端配置分别是setCommandFactory和setShellFactory。还有一类是 subsystemSFTP 就是通过 subsystem 机制实现的对应setSubsystemFactories。3.3 一个可复用的自定义命令实现业务里最常见的需求是客户端发起一个exec请求服务端只允许执行白名单里的命令不直接跑系统命令。这里可以自定义一个 Commandimport org.apache.sshd.server.Environment; import org.apache.sshd.server.ExitCallback; import org.apache.sshd.server.channel.ChannelSession; import org.apache.sshd.server.command.AbstractCommandSupport; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; public class HelloCommand extends AbstractCommandSupport { public HelloCommand(String command) { super(command); } Override public void run() { try { OutputStream out getOutputStream(); out.write(hello from mina sshd\r\n.getBytes(StandardCharsets.UTF_8)); out.flush(); getExitCallback().onExit(0); } catch (IOException e) { getExitCallback().onExit(1, e.getMessage()); } } }然后把服务端配置补上sshd.setCommandFactory(command - { if (hello.equals(command)) { return new HelloCommand(command); } // 返回 null 会导致客户端收到 “Unsupported command” return null; });这时再执行ssh -p 2222 adminlocalhost hello就能在客户端看到输出。注意一点AbstractCommandSupport内部已经帮你管理了 input/output/error 流和退出回调比直接实现Command接口省事。如果你的版本里找不到这个类翻一下org.apache.sshd.server.command包写法差不多。我也建议在业务里不要直接返回一个ProcessShellFactory那等于让远程用户执行任意系统命令。白名单命令更安全而且日志审计也更好做。4. 认证体系密码、公钥、登录限流的完整配置4.1 密码认证的常见误区第 2 章的密码认证示例是最朴素的写法但生产环境至少有四个问题要处理一是密码校验不能同步阻塞。PasswordAuthenticator里的代码跑在 MINA SSHD 的 IO 线程上如果在里面查数据库、调 Redis、做慢哈希会把整个服务器的网络事件循环堵住。正确的做法是缓存用户信息或者把认证逻辑异步化再或者在前面加一个内存限流。二是密码不能明文存储。这个应该是常识了但很多人拿到库之后第一版还是用admin.equals(password)这种代码。生产环境至少用 BCrypt 一类的慢哈希算法认证器里拿到的是客户端传输的明文和数据库里的 BCrypt 密文做bcrypt.matches(password, storedHash)。三是回调里能拿到ServerSession可以获取客户端 IP、端口、协议版本等信息。顺手记录审计日志非常方便。四是认证失败不要太早返回。有人会为了提高安全性在密码错误时立刻返回 false实际上这会让暴力破解变得更高效。最好做一个固定延迟比如失败后 sleep 300ms避免攻击者快速枚举。4.2 公钥认证与 authorized_keys 验证公钥认证在很多场景下比密码好用尤其是自动化采集和 VSCode Remote SSH。配置方式在客户端通常是把公钥放到~/.ssh/authorized_keys但 MINA SSHD 不会自动读系统这个文件需要你自己写PublickeyAuthenticator。这里的关键是解析 OpenSSH 公钥格式。一行典型的 authorized_keys 长这样ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKt... commentMINA SSHD 提供了现成的解析类比如PublicKeyEntry。可以这样读取import org.apache.sshd.common.config.keys.PublicKeyEntry; ListPublicKeyEntry entries PublicKeyEntry.readPublicKeyEntries(path); ListPublicKey keys new ArrayList(); for (PublicKeyEntry entry : entries) { keys.add(entry.resolvePublicKey()); }之后在PublickeyAuthenticator中做比对sshd.setPublickeyAuthenticator((username, key, session) - { ListPublicKey authorized userKeys.get(username); if (authorized null) return false; for (PublicKey pub : authorized) { if (pub.equals(key)) { return true; } } return false; });这里用的equals是基于密钥材料和参数比较的实际测试下来可以正确匹配 OpenSSH 生成的 ed25519 和 RSA 公钥。如果是带证书的 key需要额外解析证书里的公钥不展开但思路一致。4.3 动态认证限流、白名单、审计把认证器串起来可以实现更复杂的策略。比如我的一个项目里要求密码认证和公钥认证只要通过一个就行但密码连续失败 5 次就封禁 10 分钟另外还要限制某些网段只能用公钥登录。大致实现思路是维护一个内存 Map以客户端 IP 为 key记录失败次数和封禁截止时间sshd.setPasswordAuthenticator((username, password, session) - { String clientIp session.getIoSession().getRemoteAddress().toString(); if (isBlocked(clientIp)) { return false; } boolean ok passwordService.verify(username, password); if (!ok) { recordFailure(clientIp, username); } return ok; });在真实生产环境里我还会在SshServer上挂SessionListener监听 session 的创建、认证成功、断开等事件把这些事件全部打到日志和指标系统。这对排查“谁在尝试登录”很有价值。5. 客户端 API 实战批量远程命令执行5.1 一次标准连接流程MINA SSHD 的客户端 API 设计得和服务端对称。最基本的远程命令执行流程如下import org.apache.sshd.client.SshClient; import org.apache.sshd.client.channel.ChannelExec; import org.apache.sshd.client.session.ClientSession; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; public class RemoteCommandRunner { public static void main(String[] args) throws Exception { SshClient client SshClient.setUpDefaultClient(); client.start(); try (ClientSession session client .connect(admin, 10.0.0.10, 22) .verify(10, TimeUnit.SECONDS) .getSession()) { session.addPasswordIdentity(password123); session.auth().verify(10, TimeUnit.SECONDS); try (ChannelExec exec session.createExecChannel(df -h)) { ByteArrayOutputStream out new ByteArrayOutputStream(); ByteArrayOutputStream err new ByteArrayOutputStream(); exec.setOut(out); exec.setErr(err); exec.open().verify(10, TimeUnit.SECONDS); exec.waitFor(ChannelExec.EXIT_SIGNAL, 10000); System.out.println(STDOUT: out.toString(StandardCharsets.UTF_8)); System.out.println(STDERR: err.toString(StandardCharsets.UTF_8)); System.out.println(EXIT: exec.getExitStatus()); } } client.stop(); } }注意verify和waitFor的区别。verify是等待某个异步操作完成比如连接建立、认证完成、channel 打开waitFor是等待 channel 进入某个状态这里等的是EXIT_SIGNAL也就是命令已经执行结束。这两个超时都必须设置否则遇到网络黑洞时线程会一直挂住。5.2 并发批量执行时的设计批量远程执行最忌讳的做法是对每台机器都 new 一个SshClient。SshClient是重量级对象内部有连接池、调度线程和 IO 线程全局只应该创建一个。每台机器创建一个ClientSession用完就关。我的一个批量采集项目结构大概是这样的全局创建一个SshClient用PostConstruct启动。需要采集时把主机列表按规则切分成若干批次每批 10 台。每台主机一个任务任务里连接、认证、执行命令、关闭 session。用一个固定大小线程池控制并发避免突发流量打满 SSH Server 的连接数。伪代码ExecutorService pool Executors.newFixedThreadPool(10); for (String host : hosts) { pool.submit(() - { try (ClientSession session client.connect(user, host, 22) .verify(8, TimeUnit.SECONDS).getSession()) { session.addPasswordIdentity(password); session.auth().verify(8, TimeUnit.SECONDS); // 执行命令... } }); } pool.shutdown(); pool.awaitTermination(5, TimeUnit.MINUTES);这里有一个很隐蔽的坑try-with-resources关闭 session 时如果还有未完成的 channel 或者有正在传输的数据可能触发异常。最好在关闭 session 前显式关闭自己创建的 channel。5.3 输出读取和超时的一些坑我在这部分踩过最痛的坑是输出流的读取时机。远程命令如果输出特别大比如cat一个几百 MB 的日志文件你把输出全塞进ByteArrayOutputStream可能会导致 OOM。正确做法是设置exec.setOut()为一个流式处理器或者直接传入一个文件输出流边读边写。另一个坑是命令本身卡住比如远程命令在等待输入。虽然你设置了waitFor(EXIT_SIGNAL, 10s)但超时之后必须主动关闭 channel否则底层连接会一直挂着。我一般会写成if (!exec.waitFor(ChannelExec.EXIT_SIGNAL, timeoutMillis)) { exec.close(true); throw new RuntimeException(Command execution timeout); }最后是字符集。很多服务器默认 locale 是C命令输出不会有问题但如果跑的是中文应用输出 GBK 内容你用 UTF-8 解码就会出现乱码。稳妥的做法是连接前先export LANGC.UTF-8或者干脆按服务器环境配置解码字符集。6. SFTP 子系统文件传输和目录权限限制6.1 服务端启用 SFTP第 2 章已经加过 SFTP 子系统sshd.setSubsystemFactories( Collections.singletonList(new SftpSubsystemFactory()));这样客户端就能用sftp命令访问这台服务器的文件系统。值得注意的是默认情况下 SFTP 用户可以看到服务器上所有他能访问的路径这对隔离用户非常不利。6.2 限制用户活动范围MINA SSHD 提供了VirtualFileSystemFactory可以把某个用户限制到一个虚拟根目录。这个类会在每个 session 建立时为指定用户返回一个自定义的FileSystemSFTP 操作在逻辑上就只在这个目录里打转。import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory; sshd.setFileSystemFactory(session - { String home /data/sftp/ session.getUsername(); return new VirtualFileSystemFactory( Paths.get(home)); });这里有一个细节VirtualFileSystemFactory在较新版本里被封装成FileSystemFactory的默认实现但构造方式类似。实际写的时候注意看你的包版本如果找不到VirtualFileSystemFactory直接搜sshd-common或sshd-core下的virtualfs包。限制目录之后还要配合操作系统权限来控写。不要用 root 跑 Java 进程给每个运维用户建独立账号只授予对应目录的权限这样即使 SSH 服务被攻破伤害面也小很多。6.3 客户端上传下载的实现客户端调用 SFTP 也非常简单import org.apache.sshd.client.channel.ChannelSftp; import org.apache.sshd.client.subsystem.sftp.SftpClient; try (ChannelSftp sftp session.createSftpChannel()) { sftp.open().verify(5, TimeUnit.SECONDS); // 上传 sftp.put(/tmp/local.txt, /upload/remote.txt, null, SftpClient.OpenMode.Write, SftpClient.OpenMode.Create, SftpClient.OpenMode.Truncate); // 下载 sftp.get(/upload/remote.txt, /tmp/download.txt); }ChannelSftp是面向用户的高级封装和 JSch 的ChannelSftp是两个东西。在 MINA SSHD 里如果你想更底层操作也可以用session.createSftpClient()返回SftpClient接口支持更细粒度的方法。大多数业务用ChannelSftp就够了。7. 端口转发把 SSH 变成轻量级隧道工具7.1 本地转发与远程转发原理SSH 不只是远程 shell它还能做端口转发。本地转发的意思是客户端监听本地一个端口所有进入这个端口的流量都被封装进 SSH 隧道从服务器端发出去访问目标主机。远程转发正好相反服务器监听一个端口流量通过 SSH 隧道回到客户端这边再从客户端访问目标网络。很多内网穿透工具都脱胎于这个原理。你可以把 SSH 隧道理解成一条加密管道数据从一端进去另一端出来管道中间的所有内容对网络旁路人都是不可见的。7.2 通过 MINA SSHD 暴露内网服务在客户端建立连接后启动本地转发try (ClientSession session client.connect(user, serverHost, 22) .verify(10, TimeUnit.SECONDS).getSession()) { session.addPasswordIdentity(password); session.auth().verify(10, TimeUnit.SECONDS); session.startLocalPortForwarding(8080, internal-web, 80); // 此时访问本机 8080 端口实际上会通过 SSH 隧道到达 internal-web:80 TimeUnit.SECONDS.sleep(30); }这个特性在做数据库跳板时很好用。你的开发机无法直连生产数据库但能连跳板机在本地执行一个startLocalPortForwarding就能用本地端口连接数据库客户端。远程转发需要服务端允许MINA SSHD 的默认策略比较保守。需要打开sshd.setForwardingFilter(ForwardingFilter.ALLOW_ALL);但生产环境强烈不建议直接ALLOW_ALL。应该实现一个自定义ForwardingFilter根据客户端 IP、用户名、请求的监听地址白名单来判断是否允许。远程转发相当于给客户端开了一个反向通道一旦放开客户端可以请求服务器监听任意端口安全风险等同于把内网暴露出去。7.3 转发的安全策略我现在的项目里远程转发默认关闭只有少数管理员的 session 会被允许而且审计日志里会记录每次转发的目标地址、端口、发起时间。你可以实现ForwardingFilter的canListen和canConnect方法分别控制服务端监听和客户端连接目标。端口转发是一个强大的能力同时也是最常见的攻击入口。宁可业务上多一步跳转也不要把ForwardingFilter.ALLOW_ALL留在生产代码里。8. 上线前必须处理的几个问题8.1 主机密钥不持久化的后果有很多人第一次写 MINA SSHD 时图省事直接new SimpleGeneratorHostKeyProvider()而不传路径。这样每次启动都会重新生成一个随机密钥对结果是客户端第一次连接的指纹是 A第二次连接指纹变成 BSSH 客户端会直接报警 host key mismatch并拒绝连接。正确的做法是指定持久化文件并且把它纳入备份。文件权限也要注意最好设置成仅当前用户可读写避免其他本地用户偷走主机密钥后伪装服务器。8.2 线程模型和阻塞操作MINA SSHD 内部使用的是自己实现的一套 IO 调度很多回调方法都跑在 IO 事件线程上。初学的时候很容易在Command.run()里调用一个费时的第三方接口结果发现并发一高整个 SSH Server 响应变慢。解决办法有两个方向一个是不要在回调里做慢操作把任务提交到一个专门的线程池另一个是用SshServer#setExecutorService给服务端配置线程池。命令行执行本身天然是阻塞的所以如果你有大量 exec 请求一定要单独配置线程池不要把默认的 IO 线程池拖垮。8.3 客户端兼容性的实测记录我在实际项目中用了 VSCode Remote-SSH 和 OpenSSH 客户端连接 MINA SSHD整体兼容性比预期好但有几个细节需要注意VSCode Remote-SSH 需要服务端提供 SFTP 子系统否则远程文件树加载不出来。如果只配置了CommandFactory而没有配置ShellFactoryssh host进入交互 shell 会失败但 VSCode 的 Remote-SSH 内部会优先走 SFTP 和 exec所以影响不大。老版本 Windows 自带的 SSH 客户端可能只支持ssh-rsa签名算法而 OpenSSH 8 之后默认禁用了 ssh-rsa。如果遇到 “no mutual signature algorithm” 这类报错要么提升客户端版本要么在服务端额外配置 RSA 主机密钥。连接时的算法协商失败有很大概率是 MINA SSHD 版本过旧。升级到 2.x 最新版本后ed25519、curve25519-sha256 这些现代算法都默认支持。8.4 日志与异常排查排查 MINA SSHD 问题我第一步总是打开org.apache.sshd的 DEBUG 日志。如果使用 Logback配置logger nameorg.apache.sshd levelDEBUG/日志里能看到密钥交换、认证、channel 打开的具体过程。比如“Auth failed”会明确告诉你哪个认证器没通过“Unsupported command”会显示客户端请求的命令内容。这里列几个常见问题基本覆盖了我的踩坑记录现象常见原因建议处理连接被拒绝端口没启动或防火墙拦截先检查进程和监听端口密钥协商失败服务端主机密钥或算法不匹配更新依赖版本检查算法列表密码错误但代码正确认证回调抛异常被吞掉看 debug 日志检查异常堆栈命令返回空CommandFactory 未设置或返回 null实现 CommandFactory返回有效 Command批量执行线程泄漏session/channel 未关闭用 try-with-resources显式关闭文件上传慢网络波动或 SFTP 默认窗口小适当调整调优参数或分批传输最后提一点个人经验MINA SSHD 这套库虽然自带了很多便捷的注解和默认实现但它并不是一个开箱即用的“全能 SSH 服务器”。每个生产项目都应该根据自己的安全要求把认证、命令白名单、目录虚拟化、端口转发策略都显式配置一遍。把这些流程固定下来之后这个嵌入式 SSH 才能真的变成业务平台里一个稳定的基础设施。