docs/src/content/docs/principles/ssh.md
Understanding SSH connections in Server Box.
User Input → Spi Config → genClient() → SSH Client → Session
The Spi (Server Parameter Info) model contains:
class Spi {
String name; // Server name
String ip; // IP address
int port; // SSH port (default 22)
String user; // Username
String? pwd; // Password (encrypted)
String? keyId; // SSH key ID
String? jumpId; // Jump server ID
String? alterUrl; // Alternative URL
}
genClient(spi) creates SSH client:
Future<SSHClient> genClient(Spi spi) async {
// 1. Establish socket
final socket = await connect(spi.ip, spi.port);
// 2. Try alternative URL if failed
if (socket == null && spi.alterUrl != null) {
socket = await connect(spi.alterUrl, spi.port);
}
// 3. Authenticate
final client = SSHClient(
socket: socket,
username: spi.user,
onPasswordRequest: () => spi.pwd,
onIdentityRequest: () => loadKey(spi.keyId),
);
// 4. Verify host key
await verifyHostKey(client, spi);
return client;
}
For jump servers, recursive connection:
if (spi.jumpId != null) {
final jumpClient = await genClient(getJumpSpi(spi.jumpId));
final forwarded = await jumpClient.forwardLocal(
spi.ip,
spi.port,
);
// Connect through forwarded socket
}
onPasswordRequest: () => spi.pwd
onIdentityRequest: () async {
final key = await KeyStore.get(spi.keyId);
return decyptPem(key.pem, key.password);
}
Key Loading Process:
KeyStoreonUserInfoRequest: (instructions) async {
// Handle challenge-response
return responses;
}
Supports:
Prevents Man-in-the-Middle (MITM) attacks by ensuring you're connecting to the same server.
{spi.id}::{keyType}
Example:
my-server::ssh-ed25519
my-server::ecdsa-sha2-nistp256
MD5 Hex:
aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99
Base64:
SHA256:AbCdEf1234567890...=
Future<void> verifyHostKey(SSHClient client, Spi spi) async {
final key = await client.hostKey;
final fingerprint = md5Hex(key); // or base64
final stored = SettingStore.sshKnownHostsFingerprints
['$keyId::$keyType'];
if (stored == null) {
// New host - prompt user
final trust = await promptUser(
'Unknown host',
'Fingerprint: $fingerprint',
);
if (trust) {
SettingStore.sshKnownHostsFingerprints
['$keyId::$keyType'] = fingerprint;
}
} else if (stored != fingerprint) {
// Changed - warn user
await warnUser(
'Host key changed!',
'Possible MITM attack',
);
}
}
Active clients maintained in ServerProvider:
class ServerProvider {
final Map<String, SSHClient> _clients = {};
SSHClient getClient(String spiId) {
return _clients[spiId] ??= connect(spiId);
}
}
Maintain connection during inactivity:
Timer.periodic(
Duration(seconds: 30),
(_) => client.sendKeepAlive(),
);
On connection loss:
client.onError.listen((error) async {
await Future.delayed(Duration(seconds: 5));
reconnect();
});
┌─────────────┐
│ Initial │
└──────┬──────┘
│ connect()
↓
┌─────────────┐
│ Connecting │ ←──┐
└──────┬──────┘ │
│ success │
↓ │ fail (retry)
┌─────────────┐ │
│ Connected │───┘
└──────┬──────┘
│
↓
┌─────────────┐
│ Active │ ──→ Send commands
└──────┬──────┘
│
↓ (error/disconnect)
┌─────────────┐
│ Disconnected│
└─────────────┘
try {
await client.connect().timeout(
Duration(seconds: 30),
);
} on TimeoutException {
throw ConnectionException('Connection timeout');
}
onAuthFail: (error) {
if (error.contains('password')) {
return 'Invalid password';
} else if (error.contains('key')) {
return 'Invalid SSH key';
}
return 'Authentication failed';
}
onHostKeyMismatch: (stored, current) {
showSecurityWarning(
'Host key has changed!',
'Possible MITM attack',
);
}