SSH 连接
下文介绍 Server Box 如何建立、验证和复用 SSH 连接。通过 Monitor agent 添加的服务器不包含 SSH 凭据,因此不适用以下内容。
用户配置 → Spi → genClient() → SSH client → SessionSpi(Server Parameter Info)包含服务器和连接信息:
class Spi { String id; // 唯一标识 String name; // 服务器名称 SshCredential? ssh; // 未配置 SSH 时为 null MonitorHttpCredential? monitorHttp;}
final class SshCredential { String ip; // IP 地址或域名 int port; // SSH 端口,默认 22 String user; // 用户名 String? pwd; // 密码,加密存储 String? keyId; // SSH key ID String? alterUrl; // 备用 URL List<String>? jumpIds; // Jump server 链 String? proxyCommand; // ProxyCommand bool allowLegacyAlgorithms; // 允许协商已被 SSH 淘汰的算法,默认关闭}Jump server 链与 ProxyCommand 互斥。两者同时配置时,Spix.validate() 会拒绝该服务器配置。
兼容旧版算法
Section titled “兼容旧版算法”dartssh2 默认只提供现代算法。RSA 主机密钥仍可使用,但只采用 RFC 8332 定义的 rsa-sha2-256 和 rsa-sha2-512。旧的 SHA-1 ssh-rsa 名称、SHA-1 密钥交换、CBC cipher 和 SHA-1/MD5 MAC 均不在默认列表中。路由器或交换机上的旧版 Dropbear 可能只提供 ssh-rsa,导致握手在认证前结束:
SSHAuthAbortError(... reason: SSHInternalError( Bad state: No matching host key algorithm))SshCredential.allowLegacyAlgorithms 是服务器编辑页 SSH 高级 中的单独开关。主机密钥、密钥交换、cipher 和 MAC 会分别协商;每一类的旧算法都排在现代算法之后。因此,只有某一类没有现代选项时才会回退。例如,设备拥有现代主机密钥但只支持 SHA-1 密钥交换时,连接仍会保留现代主机密钥,仅回退密钥交换。
这些算法因安全性不足而被淘汰,包括 SHA-1 签名和密钥交换,以及较小的 Diffie-Hellman group。启用该开关意味着允许比默认配置更弱的连接。它不会让对端将本可使用现代算法的连接强制降级,因为主机密钥签名覆盖了 KEXINIT algorithm list。请仅对无法使用现代算法连接、且你信任的设备启用。
创建 client
Section titled “创建 client”genClient(spi) 会创建并返回 SSH client:
Future<SSHClient> genClient(Spi spi) async { final ssh = spi.ssh!; // 1. 建立 socket;失败后尝试备用主机、用户和端口。 SSHSocket? socket; var connectUser = ssh.user; try { socket = await connect(ssh.ip, ssh.port); } catch (_) { if (ssh.alterUrl == null) rethrow; final (alterHost, parsedUser, alterPort) = ssh.parseAlterUrl(); socket = await connect(alterHost, alterPort); connectUser = parsedUser; }
// 2. 身份验证 final client = SSHClient( socket: socket!, username: connectUser, onPasswordRequest: () => ssh.pwd, onIdentityRequest: () => loadKey(ssh.keyId), );
// 3. 验证 host key await verifyHostKey(client, spi);
return client;}Socket 来源
Section titled “Socket 来源”genClient 会从以下三种方式中选择一种。无论 socket 来源如何,建立 SSH client 后的处理都相同。
直连:使用 SSHSocket.connect(ip, port)。连接失败时,如果配置了 alterUrl,会尝试备用地址。
Jump server:按配置的候选顺序连接 jump server,再通过本地转发访问目标主机:
for (final jumpId in spi.resolvedJumpIds) { final jumpClient = await genClient(getJumpSpi(jumpId)); return await jumpClient.forwardLocal(ssh.ip, ssh.port);}ProxyCommand:在本地 shell 中运行。桌面端用系统自带的 shell;Android 在内置 Linux 环境中通过 proot 运行;iOS 在 iSH 引擎中运行,终端切换为 raw 模式,SSH 的字节原样通过。手机上需要先安装一个 Linux 系统,并在其中安装命令用到的工具(nc、socat 等):
if (ssh.proxyCommand != null) { return await ProxyCommandSocket.connect( command: ssh.proxyCommand, host: ssh.ip, port: ssh.port, user: ssh.user, );}onPasswordRequest: () => ssh.pwd密码以加密形式保存在 SQLite 中,连接时解密并发送给服务器验证。
Private key
Section titled “Private key”onIdentityRequest: () async { final key = await PrivateKeyStore.get(ssh.keyId); return decryptPem(key.pem, key.password);}Private key 的加载流程:
- 从
PrivateKeyStore读取加密的 key。 - 解密 key 密码;根据设置可能需要生物识别或再次确认。
- 解析 PEM 格式。
- 统一换行符为 LF。
- 将 key 提供给 SSH client。
Keyboard-interactive
Section titled “Keyboard-interactive”onUserInfoRequest: (instructions) async { // 处理 challenge-response return responses;}可用于密码、OTP token 和双因素认证(2FA)。
Host key 验证
Section titled “Host key 验证”首次连接时记录服务器 host key,后续连接比较已记录的 key,可帮助检测中间人(MITM)攻击。
存储 key
Section titled “存储 key”{spi.id}::{keyType}示例:
my-server::ssh-ed25519my-server::ecdsa-sha2-nistp256Fingerprint
Section titled “Fingerprint”App 显示并保存 OpenSSH SHA-256 格式的 fingerprint:
SHA256:AbCdEf1234567890...=读取旧版本保存的值时,App 会先将其转换为当前格式。
Future<bool> verifyHostKey( HostKeyVerifier verifier, String keyType, Uint8List fingerprintBytes,) => verifier(keyType, fingerprintBytes);HostKeyVerifier 将收到的 fingerprint 与 spi.id::keyType 对应的已保存值比较。未知 key 只有在你确认提示后才会信任;key 不匹配时必须再次明确确认。拒绝会返回 false,SSH 连接也会被拒绝。接受的 key 会持久化,供后续连接比较。
Session 管理
Section titled “Session 管理”ServerProvider 维护活动 client,在终端、命令和文件功能之间复用连接:
class ServerProvider { final Map<String, SSHClient> _clients = {};
SSHClient getClient(String spiId) { return _clients[spiId] ??= connect(spiId); }}Keep-alive
Section titled “Keep-alive”客户端在空闲期间发送 keep-alive 消息:
Timer.periodic( Duration(seconds: 30), (_) => client.sendKeepAlive(),);连接丢失后,provider 会等待一段时间再尝试重连:
client.onError.listen((error) async { await Future.delayed(Duration(seconds: 5)); reconnect();});连接生命周期
Section titled “连接生命周期”┌─────────────┐│ 初始化 │└──────┬──────┘ │ connect() ↓┌─────────────┐│ 连接中 │ ←──┐└──────┬──────┘ │ │ 成功 │ ↓ │ 失败,重试┌─────────────┐ ││ 已连接 │───┘└──────┬──────┘ │ ↓┌─────────────┐│ 使用中 │ ──→ 发送命令或打开 session└──────┬──────┘ │ ↓ 错误或断开┌─────────────┐│ 已断开 │└─────────────┘常见错误包括:
- 连接超时:检查地址、端口、防火墙和网络路由。
- 身份验证失败:检查用户名、密码、private key 或 keyboard-interactive 配置。
- Host key 不匹配:不要直接接受新 key。先核对服务器身份;确认服务器确实更换了 key 后,再在 Known Hosts 设置中删除旧记录并重新连接。
- 在不同功能之间复用已有 client。
- 避免不必要的断开和重连。
- 通过单个 SSH 连接执行多个操作。
- 只有在确实需要多个独立 session 时才创建额外连接。
- 调整超时、keep-alive 和重试间隔时,应结合网络延迟和服务器限制测试。