feat: import servers from ~/.ssh/config (#873)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-08-31 19:33:29 +08:00
committed by GitHub
parent a97b3cf43e
commit 12a243d139
42 changed files with 2850 additions and 334 deletions

View File

@@ -78,7 +78,7 @@ Future<SSHClient> genClient(
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.fromStringUrl();
final res = spi.parseAlterUrl();
alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) {

View File

@@ -0,0 +1,84 @@
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/store/server.dart';
class ServerDeduplication {
/// Remove duplicate servers from the import list based on existing servers
/// Returns the deduplicated list
static List<Spi> deduplicateServers(List<Spi> importedServers) {
final existingServers = ServerStore.instance.fetch();
final deduplicated = <Spi>[];
for (final imported in importedServers) {
if (!_isDuplicate(imported, existingServers)) {
deduplicated.add(imported);
}
}
return deduplicated;
}
/// Check if an imported server is a duplicate of an existing server
static bool _isDuplicate(Spi imported, List<Spi> existing) {
for (final existingSpi in existing) {
if (imported.isSameAs(existingSpi)) {
return true;
}
}
return false;
}
/// Resolve name conflicts by appending suffixes
static List<Spi> resolveNameConflicts(List<Spi> importedServers) {
final existingServers = ServerStore.instance.fetch();
final existingNames = existingServers.map((s) => s.name).toSet();
final processedNames = <String>{};
final result = <Spi>[];
for (final server in importedServers) {
String newName = server.name;
int suffix = 1;
// Check against both existing servers and already processed servers
while (existingNames.contains(newName) || processedNames.contains(newName)) {
newName = '${server.name} ($suffix)';
suffix++;
}
processedNames.add(newName);
if (newName != server.name) {
result.add(server.copyWith(name: newName));
} else {
result.add(server);
}
}
return result;
}
/// Get summary of import operation
static ImportSummary getImportSummary(List<Spi> originalList, List<Spi> deduplicatedList) {
final duplicateCount = originalList.length - deduplicatedList.length;
return ImportSummary(
total: originalList.length,
duplicates: duplicateCount,
toImport: deduplicatedList.length,
);
}
}
class ImportSummary {
final int total;
final int duplicates;
final int toImport;
const ImportSummary({
required this.total,
required this.duplicates,
required this.toImport,
});
bool get hasDuplicates => duplicates > 0;
bool get hasItemsToImport => toImport > 0;
}

View File

@@ -0,0 +1,187 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
/// Utility class to parse SSH config files under `~/.ssh/config`
abstract final class SSHConfig {
static const String _defaultPath = '~/.ssh/config';
static String? get _homePath {
final homePath = isWindows ? Platform.environment['USERPROFILE'] : Platform.environment['HOME'];
if (homePath == null || homePath.isEmpty) {
return null;
}
return homePath;
}
/// Get possible SSH config file paths, with macOS-specific handling
static List<String> get _possibleConfigPaths {
final paths = <String>[];
final homePath = _homePath;
if (homePath != null) {
// Standard path
paths.add('$homePath/.ssh/config');
// On macOS, also try the actual user home directory
if (isMacOS) {
// Try to get the real user home directory
final username = Platform.environment['USER'];
if (username != null) {
paths.add('/Users/$username/.ssh/config');
}
}
}
return paths;
}
/// Parse SSH config file and return a list of Spi objects
static Future<List<Spi>> parseConfig([String? configPath]) async {
final (file, exists) = configExists(configPath);
if (!exists || file == null) {
Loggers.app.info('SSH config file does not exist at path: ${configPath ?? _defaultPath}');
return [];
}
final content = await file.readAsString();
return _parseSSHConfig(content);
}
/// Parse SSH config content
static List<Spi> _parseSSHConfig(String content) {
final servers = <Spi>[];
final lines = content.split('\n');
String? currentHost;
String? hostname;
String? user;
int port = 22;
String? identityFile;
String? jumpHost;
void addServer() {
if (currentHost != null && currentHost != '*' && hostname != null) {
final spi = Spi(
name: currentHost,
ip: hostname,
port: port,
user: user ?? 'root', // Default user is 'root'
keyId: identityFile,
jumpId: jumpHost,
);
servers.add(spi);
}
}
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
// Handle inline comments
final commentIndex = trimmed.indexOf('#');
final cleanLine = commentIndex != -1 ? trimmed.substring(0, commentIndex).trim() : trimmed;
if (cleanLine.isEmpty) continue;
final parts = cleanLine.split(RegExp(r'\s+'));
if (parts.length < 2) continue;
final key = parts[0].toLowerCase();
var value = parts.sublist(1).join(' ');
// Remove quotes from values
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length - 1);
}
switch (key) {
case 'host':
// Save previous host config
addServer();
// Reset for new host
final originalValue = parts.sublist(1).join(' ');
final isQuoted =
(originalValue.startsWith('"') && originalValue.endsWith('"')) ||
(originalValue.startsWith("'") && originalValue.endsWith("'"));
currentHost = value;
// Skip hosts with multiple patterns (contains spaces but not quoted)
if (currentHost.contains(' ') && !isQuoted) {
currentHost = null; // Mark as invalid to skip
}
hostname = null;
user = null;
port = 22;
identityFile = null;
jumpHost = null;
break;
case 'hostname':
hostname = value;
break;
case 'user':
user = value;
break;
case 'port':
port = int.tryParse(value) ?? 22;
break;
case 'identityfile':
identityFile = value; // Store the path directly
break;
case 'proxyjump':
case 'proxycommand':
jumpHost = _extractJumpHost(value);
break;
}
}
// Add the last server
addServer();
return servers;
}
/// Extract jump host from ProxyJump or ProxyCommand
static String? _extractJumpHost(String value) {
// For ProxyJump, the format is usually: user@host:port
// For ProxyCommand, it's more complex and might need custom parsing
if (value.contains('@')) {
return value.split(' ').first;
}
return null;
}
/// Check if SSH config file exists, trying multiple possible paths
static (File?, bool) configExists([String? configPath]) {
if (configPath != null) {
// If specific path is provided, use it directly
final homePath = _homePath;
if (homePath == null) {
Loggers.app.warning('Cannot determine home directory for SSH config parsing.');
return (null, false);
}
final expandedPath = configPath.replaceFirst('~', homePath);
dprint('Checking SSH config at path: $expandedPath');
final file = File(expandedPath);
return (file, file.existsSync());
}
// Try multiple possible paths
for (final path in _possibleConfigPaths) {
dprint('Checking SSH config at path: $path');
final file = File(path);
if (file.existsSync()) {
dprint('Found SSH config at: $path');
return (file, true);
}
}
dprint('SSH config file not found in any of the expected locations');
return (null, false);
}
}