跳转到内容

SSH 连接

下文介绍 Server Box 如何建立、验证和复用 SSH 连接。通过 Monitor agent 添加的服务器不包含 SSH 凭据,因此不适用以下内容。

用户配置 → Spi → genClient() → SSH client → Session

Spi(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() 会拒绝该服务器配置。

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。请仅对无法使用现代算法连接、且你信任的设备启用。

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;
}

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 中,连接时解密并发送给服务器验证。

onIdentityRequest: () async {
final key = await PrivateKeyStore.get(ssh.keyId);
return decryptPem(key.pem, key.password);
}

Private key 的加载流程:

  1. 从 PrivateKeyStore 读取加密的 key。
  2. 解密 key 密码;根据设置可能需要生物识别或再次确认。
  3. 解析 PEM 格式。
  4. 统一换行符为 LF。
  5. 将 key 提供给 SSH client。
onUserInfoRequest: (instructions) async {
// 处理 challenge-response
return responses;
}

可用于密码、OTP token 和双因素认证(2FA)。

首次连接时记录服务器 host key,后续连接比较已记录的 key,可帮助检测中间人(MITM)攻击。

{spi.id}::{keyType}

示例:

my-server::ssh-ed25519
my-server::ecdsa-sha2-nistp256

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 会持久化,供后续连接比较。

ServerProvider 维护活动 client,在终端、命令和文件功能之间复用连接:

class ServerProvider {
final Map<String, SSHClient> _clients = {};
SSHClient getClient(String spiId) {
return _clients[spiId] ??= connect(spiId);
}
}

客户端在空闲期间发送 keep-alive 消息:

Timer.periodic(
Duration(seconds: 30),
(_) => client.sendKeepAlive(),
);

连接丢失后,provider 会等待一段时间再尝试重连:

client.onError.listen((error) async {
await Future.delayed(Duration(seconds: 5));
reconnect();
});
┌─────────────┐
│ 初始化 │
└──────┬──────┘
│ connect()
↓
┌─────────────┐
│ 连接中 │ ←──┐
└──────┬──────┘ │
│ 成功 │
↓ │ 失败,重试
┌─────────────┐ │
│ 已连接 │───┘
└──────┬──────┘
│
↓
┌─────────────┐
│ 使用中 │ ──→ 发送命令或打开 session
└──────┬──────┘
│
↓ 错误或断开
┌─────────────┐
│ 已断开 │
└─────────────┘

常见错误包括:

  • 连接超时:检查地址、端口、防火墙和网络路由。
  • 身份验证失败:检查用户名、密码、private key 或 keyboard-interactive 配置。
  • Host key 不匹配:不要直接接受新 key。先核对服务器身份;确认服务器确实更换了 key 后,再在 Known Hosts 设置中删除旧记录并重新连接。
  • 在不同功能之间复用已有 client。
  • 避免不必要的断开和重连。
  • 通过单个 SSH 连接执行多个操作。
  • 只有在确实需要多个独立 session 时才创建额外连接。
  • 调整超时、keep-alive 和重试间隔时,应结合网络延迟和服务器限制测试。