Files
flutter_server_box/lib/core/utils/server.dart
2025-10-20 09:31:20 +08:00

355 lines
11 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/app_navigator.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/res/store.dart';
/// Must put this func out of any Class.
///
/// Because of this function is called by [compute].
///
/// https://stackoverflow.com/questions/51998995/invalid-arguments-illegal-argument-in-isolate-message-object-is-a-closure
List<SSHKeyPair> loadIndentity(String key) {
return SSHKeyPair.fromPem(key);
}
/// [args] : [key, pwd]
String decyptPem(List<String> args) {
/// skip when the key is not encrypted, or will throw exception
if (!SSHKeyPair.isEncryptedPem(args[0])) return args[0];
final sshKey = SSHKeyPair.fromPem(args[0], args[1]);
return sshKey.first.toPem();
}
enum GenSSHClientStatus { socket, key, pwd }
String getPrivateKey(String id) {
final pki = Stores.key.fetchOne(id);
if (pki == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id));
}
return pki.key;
}
Future<SSHClient> genClient(
Spi spi, {
void Function(GenSSHClientStatus)? onStatus,
/// Only pass this param if using multi-threading and key login
String? privateKey,
/// Only pass this param if using multi-threading and key login
String? jumpPrivateKey,
Duration timeout = const Duration(seconds: 5),
/// [Spi] of the jump server
///
/// Must pass this param if using multi-threading and key login
Spi? jumpSpi,
/// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive,
Map<String, String>? knownHostFingerprints,
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
}) async {
onStatus?.call(GenSSHClientStatus.socket);
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
String? alterUser;
final socket = await () async {
// Proxy
final jumpSpi_ = () {
// Multi-thread or key login
if (jumpSpi != null) return jumpSpi;
// Main thread
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
}();
if (jumpSpi_ != null) {
final jumpClient = await genClient(
jumpSpi_,
privateKey: jumpPrivateKey,
timeout: timeout,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: onHostKeyPrompt,
);
return await jumpClient.forwardLocal(spi.ip, spi.port);
}
// Direct
try {
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.parseAlterUrl();
alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;
}
}
}();
final hostKeyVerifier = _HostKeyVerifier(
spi: spi,
cache: hostKeyCache,
persistCallback: hostKeyPersist,
prompt: hostKeyPrompt,
);
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
return SSHClient(
socket,
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
return SSHClient(
socket,
username: spi.user,
// Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
class HostKeyPromptInfo {
HostKeyPromptInfo({
required this.spi,
required this.keyType,
required this.fingerprintHex,
required this.fingerprintBase64,
required this.isMismatch,
this.previousFingerprintHex,
});
final Spi spi;
final String keyType;
final String fingerprintHex;
final String fingerprintBase64;
final bool isMismatch;
final String? previousFingerprintHex;
}
class _HostKeyVerifier {
_HostKeyVerifier({
required this.spi,
required Map<String, String> cache,
required this.prompt,
this.persistCallback,
}) : _cache = cache;
final Spi spi;
final Map<String, String> _cache;
final _HostKeyPersistCallback? persistCallback;
final Future<bool> Function(HostKeyPromptInfo info) prompt;
Future<bool> call(String keyType, Uint8List fingerprintBytes) async {
final storageKey = _hostKeyStorageKey(spi, keyType);
final fingerprintHex = _fingerprintToHex(fingerprintBytes);
final fingerprintBase64 = _fingerprintToBase64(fingerprintBytes);
final existing = _cache[storageKey];
if (existing == null) {
final accepted = await prompt(
HostKeyPromptInfo(
spi: spi,
keyType: keyType,
fingerprintHex: fingerprintHex,
fingerprintBase64: fingerprintBase64,
isMismatch: false,
),
);
if (!accepted) {
Loggers.app.warning('User rejected new SSH host key for ${spi.name} ($keyType).');
return false;
}
_cache[storageKey] = fingerprintHex;
persistCallback?.call(storageKey, fingerprintHex);
Loggers.app.info('Trusted SSH host key for ${spi.name} ($keyType).');
return true;
}
if (existing == fingerprintHex) {
return true;
}
final accepted = await prompt(
HostKeyPromptInfo(
spi: spi,
keyType: keyType,
fingerprintHex: fingerprintHex,
fingerprintBase64: fingerprintBase64,
isMismatch: true,
previousFingerprintHex: existing,
),
);
if (!accepted) {
Loggers.app.warning(
'SSH host key mismatch for ${spi.name}',
'expected $existing but received $fingerprintHex ($keyType)',
);
return false;
}
_cache[storageKey] = fingerprintHex;
persistCallback?.call(storageKey, fingerprintHex);
Loggers.app.warning('Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.');
return true;
}
}
Map<String, String> _loadKnownHostFingerprints() {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
return Map<String, String>.from(prop.get());
} catch (e, stack) {
Loggers.app.warning('Load SSH host key fingerprints failed', e, stack);
return <String, String>{};
}
}
void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
final updated = Map<String, String>.from(prop.get());
if (updated[storageKey] == fingerprintHex) {
return;
}
updated[storageKey] = fingerprintHex;
prop.put(updated);
Loggers.app.info('Stored SSH host key fingerprint for $storageKey');
} catch (e, stack) {
Loggers.app.warning('Persist SSH host key fingerprint failed', e, stack);
}
}
Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
final ctx = AppNavigator.context;
if (ctx == null) {
Loggers.app.warning('Host key prompt skipped: navigator context unavailable.');
return false;
}
final hostLine = '${info.spi.user}@${info.spi.ip}:${info.spi.port}';
final description = info.isMismatch
? l10n.sshHostKeyChangedDesc(info.spi.name)
: l10n.sshHostKeyNewDesc(info.spi.name);
final result = await ctx.showRoundDialog<bool>(
title: libL10n.attention,
barrierDismiss: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(description),
const SizedBox(height: 12),
SelectableText('${l10n.server}: ${info.spi.name}'),
SelectableText('${libL10n.addr}: $hostLine'),
SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'),
SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)),
SelectableText(l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64)),
if (info.previousFingerprintHex != null) ...[
const SizedBox(height: 12),
SelectableText(l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!)),
],
],
),
actions: [
TextButton(onPressed: () => ctx.pop(false), child: Text(libL10n.cancel)),
TextButton(onPressed: () => ctx.pop(true), child: Text(libL10n.ok)),
],
);
return result ?? false;
}
Future<void> ensureKnownHostKey(
Spi spi, {
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
}) async {
final cache = _loadKnownHostFingerprints();
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return;
}
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
await ensureKnownHostKey(
jumpSpi,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
);
cache.addAll(_loadKnownHostFingerprints());
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;
}
final client = await genClient(
spi,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: cache,
);
try {
await client.authenticated;
} finally {
client.close();
}
}
bool _hasKnownHostFingerprintForSpi(Spi spi, Map<String, String> cache) {
final prefix = '${_hostIdentifier(spi)}::';
return cache.keys.any((key) => key.startsWith(prefix));
}
String _hostKeyStorageKey(Spi spi, String keyType) {
final base = _hostIdentifier(spi);
return '$base::$keyType';
}
String _hostIdentifier(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId;
String _fingerprintToHex(Uint8List fingerprint) {
final buffer = StringBuffer();
for (var i = 0; i < fingerprint.length; i++) {
if (i > 0) buffer.write(':');
buffer.write(fingerprint[i].toRadixString(16).padLeft(2, '0'));
}
return buffer.toString();
}
String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint);