-
-
Notifications
You must be signed in to change notification settings - Fork 466
feat: discover local ssh server #921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Reviewer's GuideThis PR introduces a new SSH discovery feature by adding a background service to scan the local network for SSH servers, modeling the results, exposing a discovery page in the UI, and integrating discovery into the existing server-edit workflow with single-server autofill or multi-server batch import. Sequence diagram for SSH server discovery and import flowsequenceDiagram
actor User
participant "ServerEditPage"
participant "SshDiscoveryPage"
participant "SshDiscoveryService"
participant "Stores.server"
User->>"ServerEditPage": Tap 'Discover SSH Servers'
"ServerEditPage"->>"SshDiscoveryPage": Open discovery page
User->>"SshDiscoveryPage": Start discovery
"SshDiscoveryPage"->>"SshDiscoveryService": discover()
"SshDiscoveryService"-->>"SshDiscoveryPage": SshDiscoveryReport
"SshDiscoveryPage"->>User: Show discovered servers
User->>"SshDiscoveryPage": Select servers and import
"SshDiscoveryPage"->>"ServerEditPage": Return selected SshDiscoveryResult(s)
"ServerEditPage"->>"Stores.server": Import server(s)
"ServerEditPage"->>User: Show import result
ER diagram for SSH discovery result and report modelserDiagram
SshDiscoveryReport {
string generatedAt
int durationMs
int count
}
SshDiscoveryResult {
string ip
int port
string banner
bool isSelected
}
SshDiscoveryReport ||--o{ SshDiscoveryResult : items
Class diagram for SSH discovery models and serviceclassDiagram
class SshDiscoveryResult {
+String ip
+int port
+String? banner
+bool isSelected
+copyWith(...)
+toJson()
}
class SshDiscoveryReport {
+String generatedAt
+int durationMs
+int count
+List~SshDiscoveryResult~ items
+copyWith(...)
+toJson()
}
class SshDiscoveryConfig {
+int timeoutMs
+int maxConcurrency
+bool enableMdns
+copyWith(...)
+toArgs()
}
class SshDiscoveryService {
+static Future~SshDiscoveryReport~ discover(SshDiscoveryConfig)
}
SshDiscoveryReport "1" o-- "many" SshDiscoveryResult
SshDiscoveryService ..> SshDiscoveryReport
SshDiscoveryService ..> SshDiscoveryConfig
SshDiscoveryService ..> SshDiscoveryResult
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey there - I've reviewed your changes - here's some feedback:
- Consider making the default SSH user in _batchImportServers configurable instead of hardcoding 'root', so it can adapt to environments where a different user is expected.
- Add explicit handling or a no-op path for unsupported platforms (e.g. Windows) in SshDiscoveryService so discovery failures aren’t silent on non-Linux/macOS hosts.
- Unify your localization references (libL10n vs. l10n) in the discovery UI to keep string lookups consistent and avoid confusion.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Consider making the default SSH user in _batchImportServers configurable instead of hardcoding 'root', so it can adapt to environments where a different user is expected.
- Add explicit handling or a no-op path for unsupported platforms (e.g. Windows) in SshDiscoveryService so discovery failures aren’t silent on non-Linux/macOS hosts.
- Unify your localization references (libL10n vs. l10n) in the discovery UI to keep string lookups consistent and avoid confusion.
## Individual Comments
### Comment 1
<location> `lib/core/service/ssh_discovery.dart:32-33` </location>
<code_context>
+ }
+
+ // Filter out unwanted addresses
+ candidates.removeWhere((a) => a.isLoopback || a.isLinkLocal || a.address == '0.0.0.0');
+
+ // 4) Concurrent SSH port scanning
</code_context>
<issue_to_address>
**suggestion:** Consider filtering out broadcast and multicast addresses.
Broadcast and multicast addresses are not currently excluded, which could result in unnecessary scans and false positives.
```suggestion
// Filter out unwanted addresses: loopback, link-local, 0.0.0.0, broadcast, multicast
bool _isBroadcastOrMulticast(a) {
// IPv4 broadcast: ends with .255 or is 255.255.255.255
if (a.type == InternetAddressType.IPv4) {
if (a.address == '255.255.255.255') return true;
if (a.address.split('.').last == '255') return true;
// Multicast: 224.0.0.0 - 239.255.255.255
final firstOctet = int.tryParse(a.address.split('.').first) ?? 0;
if (firstOctet >= 224 && firstOctet <= 239) return true;
} else if (a.type == InternetAddressType.IPv6) {
// IPv6 multicast: starts with ff
if (a.address.toLowerCase().startsWith('ff')) return true;
}
return false;
}
candidates.removeWhere((a) =>
a.isLoopback ||
a.isLinkLocal ||
a.address == '0.0.0.0' ||
_isBroadcastOrMulticast(a)
);
```
</issue_to_address>
### Comment 2
<location> `lib/core/service/ssh_discovery.dart:21-23` </location>
<code_context>
+
+ // 2) Enumerate small subnets from local interfaces (IPv4 only)
+ final cidrs = await _localIPv4Cidrs();
+ for (final c in cidrs) {
+ if (c.prefix >= 24 && c.prefix <= 30) {
+ candidates.addAll(c.enumerateHosts(limit: 4096));
+ }
+ }
</code_context>
<issue_to_address>
**suggestion:** Host enumeration limit is hardcoded to 4096.
Consider exposing the host enumeration limit as a parameter in SshDiscoveryConfig so users can adjust it based on their needs.
Suggested implementation:
```
static Future<SshDiscoveryReport> discover([SshDiscoveryConfig config = const SshDiscoveryConfig()]) async {
final t0 = DateTime.now();
final candidates = <InternetAddress>{};
// 1) Get neighbors from ARP/NDP tables
candidates.addAll(await _neighborsIPv4());
candidates.addAll(await _neighborsIPv6());
// 2) Enumerate small subnets from local interfaces (IPv4 only)
final cidrs = await _localIPv4Cidrs();
for (final c in cidrs) {
if (c.prefix >= 24 && c.prefix <= 30) {
candidates.addAll(c.enumerateHosts(limit: config.hostEnumerationLimit));
}
}
// 3) Optional: mDNS/Bonjour SSH services
if (config.enableMdns) {
candidates.addAll(await _mdnsSshCandidates());
}
// Filter out unwanted addresses
candidates.removeWhere((a) => a.isLoopback || a.isLinkLocal || a.address == '0.0.0.0');
```
You will also need to update the `SshDiscoveryConfig` class to add a `hostEnumerationLimit` field, with a default value of 4096, and ensure it is included in the constructor and `const` declaration. For example:
```dart
class SshDiscoveryConfig {
final bool enableMdns;
final int hostEnumerationLimit;
const SshDiscoveryConfig({
this.enableMdns = false,
this.hostEnumerationLimit = 4096,
});
}
```
</issue_to_address>
### Comment 3
<location> `lib/core/service/ssh_discovery.dart:98-106` </location>
<code_context>
+ }
+ }
+ } else if (_isMac) {
+ final s = await _run('/usr/sbin/arp', ['-an']);
+ if (s != null) {
+ for (final line in const LineSplitter().convert(s)) {
+ final m = RegExp(r'\((\d+\.\d+\.\d+\.\d+)\)').firstMatch(line);
+ if (m != null) {
+ set.add(InternetAddress(m.group(1)!));
+ }
+ }
+ }
+ }
+ return set;
+ }
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** ARP/NDP parsing may fail silently on unexpected output.
If ARP/NDP output format changes or is localized, parsing may fail and return an empty set without warning. Recommend adding error logging to aid troubleshooting.
```suggestion
final s = await _run('/usr/sbin/arp', ['-an']);
if (s != null) {
int matchCount = 0;
for (final line in const LineSplitter().convert(s)) {
final m = RegExp(r'\((\d+\.\d+\.\d+\.\d+)\)').firstMatch(line);
if (m != null) {
set.add(InternetAddress(m.group(1)!));
matchCount++;
}
}
if (matchCount == 0) {
print('[ssh_discovery] Warning: No ARP entries parsed on macOS. Output may be unexpected or localized. Output sample: $s');
}
}
```
</issue_to_address>
### Comment 4
<location> `lib/core/service/ssh_discovery.dart:155-164` </location>
<code_context>
+ final s = await _run('/sbin/ifconfig', []);
</code_context>
<issue_to_address>
**suggestion:** Parsing ifconfig output may be brittle across OS versions.
Regular expressions may not handle all variations or localized outputs. Consider a more robust parsing approach or add error handling for unexpected formats.
Suggested implementation:
```
final s = await _run('/sbin/ifconfig', []);
if (s != null) {
for (final raw in const LineSplitter().convert(s)) {
final line = raw.trimRight();
final ifMatch = RegExp(r'^([a-z0-9]+):').firstMatch(line);
if (ifMatch != null) {
continue;
}
if (line.contains('inet ') && !line.contains('127.0.0.1')) {
try {
final ipm = RegExp(
r'inet\s+(\d+\.\d+\.\d+\.\d+)\s+netmask\s+0x([0-9a-fA-F]+)(?:\s+broadcast\s+(\d+\.\d+\.\d+\.\d+))?',
).firstMatch(line);
if (ipm == null) {
// Log or handle unexpected format
print('Warning: Unexpected ifconfig line format: $line');
continue;
}
// Existing parsing logic here...
// Example:
// final ip = InternetAddress(ipm.group(1)!);
// final netmaskHex = ipm.group(2)!;
// final netmask = _hexNetmaskToPrefix(netmaskHex);
// final broadcast = ipm.group(3);
// res.add(_Cidr(ip, netmask, ...));
} catch (e) {
print('Error parsing ifconfig output: $e, line: $line');
continue;
}
}
```
- You may want to implement `_hexNetmaskToPrefix` or similar logic if not already present.
- Consider replacing `print` with your project's logging mechanism if available.
- If you want to further improve robustness, consider moving the parsing logic into a dedicated helper function.
</issue_to_address>
### Comment 5
<location> `lib/core/service/ssh_discovery.dart:282-291` </location>
<code_context>
+
+ Future<_ScanResult?> _probeSsh(InternetAddress ip) async {
+ try {
+ final socket = await Socket.connect(ip, SshDiscoveryService._sshPort, timeout: timeout);
+ socket.timeout(timeout);
+ final c = Completer<String?>();
+ late StreamSubscription sub;
+ sub = socket.listen(
+ (data) {
+ final s = utf8.decode(data, allowMalformed: true);
+ final line = s.split('\n').firstWhere((_) => true, orElse: () => s);
+ if (!c.isCompleted) {
+ c.complete(line.trim());
+ sub.cancel();
+ }
+ },
+ onDone: () {
+ if (!c.isCompleted) c.complete(null);
+ },
+ onError: (_) {
+ if (!c.isCompleted) c.complete(null);
+ },
+ );
+ final banner = await c.future.timeout(timeout, onTimeout: () => null);
+ socket.destroy();
+ return _ScanResult(ip, banner);
+ } catch (_) {
</code_context>
<issue_to_address>
**issue (bug_risk):** Socket may not be closed on all error paths.
Use a try/finally block to guarantee socket.destroy() is called even if an exception occurs.
</issue_to_address>
### Comment 6
<location> `lib/core/service/ssh_discovery.dart:286-295` </location>
<code_context>
+ socket.timeout(timeout);
+ final c = Completer<String?>();
+ late StreamSubscription sub;
+ sub = socket.listen(
+ (data) {
+ final s = utf8.decode(data, allowMalformed: true);
+ final line = s.split('\n').firstWhere((_) => true, orElse: () => s);
+ if (!c.isCompleted) {
+ c.complete(line.trim());
+ sub.cancel();
+ }
+ },
+ onDone: () {
+ if (!c.isCompleted) c.complete(null);
+ },
+ onError: (_) {
+ if (!c.isCompleted) c.complete(null);
+ },
+ );
+ final banner = await c.future.timeout(timeout, onTimeout: () => null);
+ socket.destroy();
+ return _ScanResult(ip, banner);
</code_context>
<issue_to_address>
**issue (bug_risk):** StreamSubscription may not be cancelled on all code paths.
sub.cancel() should be called after completion in all cases to prevent resource leaks.
</issue_to_address>
### Comment 7
<location> `lib/view/page/server/edit/actions.dart:28-34` </location>
<code_context>
+ );
+
+ if (shouldImport == true) {
+ final servers = discoveredServers.map((result) => Spi(
+ name: result.ip,
+ ip: result.ip,
+ port: result.port,
+ user: 'root', // Default username
+ keyId: _keyIdx.value?.toString(),
+ pwd: _passwordController.text.isEmpty ? null : _passwordController.text,
+ )).toList();
+
+ await _batchImportServers(servers);
+ }
+ }
</code_context>
<issue_to_address>
**suggestion:** Default username and keyId assignment may not suit all environments.
Consider prompting users to confirm or modify the default 'root' username and keyId before importing, or clearly indicate these defaults in the UI.
```suggestion
// Prompt user to confirm or modify default username and keyId before importing
String defaultUsername = 'root';
String defaultKeyId = _keyIdx.value?.toString() ?? '';
final importData = await showDialog<Map<String, String>>(
context: context,
builder: (context) {
final usernameController = TextEditingController(text: defaultUsername);
final keyIdController = TextEditingController(text: defaultKeyId);
return AlertDialog(
title: Text(libL10n.import),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${libL10n.found} ${discoveredServers.length} ${l10n.servers}.'),
SizedBox(height: 8),
Text('Default username: "$defaultUsername", keyId: "$defaultKeyId". You may modify these before importing.'),
SizedBox(height: 8),
TextField(
controller: usernameController,
decoration: InputDecoration(labelText: 'Username'),
),
TextField(
controller: keyIdController,
decoration: InputDecoration(labelText: 'Key ID'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: Text(libL10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop({
'username': usernameController.text,
'keyId': keyIdController.text,
}),
child: Text(libL10n.ok),
),
],
);
},
);
if (importData != null) {
final username = importData['username'] ?? defaultUsername;
final keyId = importData['keyId'] ?? defaultKeyId;
final servers = discoveredServers.map((result) => Spi(
name: result.ip,
ip: result.ip,
port: result.port,
user: username,
keyId: keyId,
pwd: _passwordController.text.isEmpty ? null : _passwordController.text,
)).toList();
await _batchImportServers(servers);
}
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This pull request adds SSH server discovery functionality and includes various localization standardizations and dependency updates.
- Implements SSH discovery service to scan local ARP/NDP tables, subnets, and optionally mDNS for SSH servers
- Integrates discovery into server editing workflow with single-server population and bulk import capabilities
- Standardizes localization usage by migrating from
l10ntolibL10nthroughout the codebase
Reviewed Changes
Copilot reviewed 33 out of 47 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pubspec.yaml | Bumps fl_lib dependency to v1.0.349 |
| lib/core/service/ssh_discovery.dart | Core SSH discovery service implementing network scanning logic |
| lib/data/model/server/discovery_result.dart | Freezed models for discovery results, reports, and configuration |
| lib/view/page/server/discovery/discovery.dart | Main discovery UI page with settings and results display |
| lib/view/page/server/edit/edit.dart | Integrates discovery button into server edit page |
| lib/view/page/server/edit/actions.dart | Implements discovery handling and server import logic |
| lib/l10n/*.arb | Reorganizes localization strings with SSH discovery terms |
| Various UI files | Standardizes l10n to libL10n usage across components |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| store.put(server); | ||
| imported++; | ||
| } catch (e) { | ||
| dprint('Failed to import server ${server.name}: $e'); |
Copilot
AI
Sep 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a more descriptive error message that includes context about the import operation failing, such as 'SSH Discovery: Failed to import server'.
| dprint('Failed to import server ${server.name}: $e'); | |
| dprint('SSH Discovery: Failed to import server ${server.name}: $e'); |
| if (_nameController.text.isEmpty) { | ||
| _nameController.text = server.ip; | ||
| } | ||
| context.showSnackBar('${libL10n.found} 1 server'); |
Copilot
AI
Sep 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using string interpolation or a proper localized message format instead of concatenating strings. This approach may not work well for all languages due to different grammar structures.
| context.showSnackBar('${libL10n.found} 1 server'); | |
| context.showSnackBar(libL10n.foundServers(1)); |
| dprint('Failed to import server ${server.name}: $e'); | ||
| } | ||
| } | ||
| context.showSnackBar('Imported $imported ${l10n.servers}'); |
Copilot
AI
Sep 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the previous issue, this string concatenation pattern should use proper localization with placeholders to handle different language grammar correctly.
| context.showSnackBar('Imported $imported ${l10n.servers}'); | |
| context.showSnackBar(l10n.importedServers(imported)); |
lib/core/service/ssh_discovery.dart
Outdated
| try { | ||
| final socket = await Socket.connect(ip, SshDiscoveryService._sshPort, timeout: timeout); |
Copilot
AI
Sep 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider adding a catch block that handles SocketException specifically to avoid creating unnecessary stack traces for expected connection failures during network scanning.
|
@claude review |
|
Claude finished @lollipopkit's task —— View job Code Review: SSH Discovery FeatureComprehensive review of the SSH server discovery implementation. Review Progress
Overall Assessment: ✅ APPROVEDThis is a well-implemented feature that adds valuable SSH server discovery functionality. The code follows repository conventions and addresses most security concerns. Key Strengths✅ Architecture & Design
✅ Security & Network Scanning
✅ Data Models & Code Generation
✅ UI Integration
Areas for Improvement
|
Fixes #916
Summary by Sourcery
Add SSH server discovery capability and integrate it into the server editing workflow.
New Features:
Enhancements: