Skip to content

Conversation

@lollipopkit
Copy link
Owner

@lollipopkit lollipopkit commented Sep 17, 2025

Fixes #916

Summary by Sourcery

Add SSH server discovery capability and integrate it into the server editing workflow.

New Features:

  • Implement a core SSH discovery service to scan local ARP/NDP tables, subnets, and optional mDNS for SSH servers.
  • Define Freezed data models (SshDiscoveryResult, SshDiscoveryReport, SshDiscoveryConfig) for discovery results and configuration.
  • Add a dedicated SshDiscoveryPage UI to configure discovery parameters, display scan results, select servers, and import them.
  • Integrate SSH discovery into the ServerEditPage, enabling single-server population or bulk import of discovered servers.

Enhancements:

  • Standardize localization calls by migrating many UI strings to use libL10n.
  • Update Riverpod-generated providers to the newer NotifierProviderImpl API.
  • Bump fl_lib dependency reference to v1.0.349 in pubspec.

@sourcery-ai
Copy link

sourcery-ai bot commented Sep 17, 2025

Reviewer's Guide

This 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 flow

sequenceDiagram
    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
Loading

ER diagram for SSH discovery result and report models

erDiagram
    SshDiscoveryReport {
      string generatedAt
      int durationMs
      int count
    }
    SshDiscoveryResult {
      string ip
      int port
      string banner
      bool isSelected
    }
    SshDiscoveryReport ||--o{ SshDiscoveryResult : items
Loading

Class diagram for SSH discovery models and service

classDiagram
    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
Loading

File-Level Changes

Change Details Files
Add SSH discovery service
  • Implement SshDiscoveryService to gather neighbors, enumerate CIDRs, optional mDNS and perform concurrent port probing
  • Define _Scanner, _Semaphore and network utilities under core/service/ssh_discovery.dart
lib/core/service/ssh_discovery.dart
Introduce data models for discovery
  • Add SshDiscoveryResult, SshDiscoveryReport, SshDiscoveryConfig with Freezed and JSON serialization
  • Generate corresponding *.g.dart and *.freezed.dart files
lib/data/model/server/discovery_result.dart
lib/data/model/server/discovery_result.freezed.dart
lib/data/model/server/discovery_result.g.dart
Build UI for discovery page
  • Create SshDiscoveryPage and its widget parts with list, summary, FAB and settings dialog
  • Integrate AppRouteNoArg for navigation
lib/view/page/server/discovery/discovery.dart
lib/view/page/server/discovery/widget.dart
Integrate discovery into server edit flow
  • Add _buildSSHDiscovery tile, _onTapSSHDiscovery handler, result processing and batch import logic
  • Populate form when one server is found or prompt import dialog for multiple servers
lib/view/page/server/edit/actions.dart
lib/view/page/server/edit/edit.dart
lib/view/page/server/edit/widget.dart
Update code generation and localization references
  • Regenerate Riverpod single notifier code to use NotifierProviderImpl
  • Switch various UI calls from l10n to libL10n for consistency
  • Bump fl_lib dependency reference in pubspec
lib/data/provider/server/single.g.dart
pubspec.yaml
multiple view and setting entry files

Assessment against linked issues

Issue Objective Addressed Explanation
#916 Implement a feature to discover local SSH servers and allow users to import them.
#916 Add UI components to initiate SSH server discovery and display discovered servers for selection/import.
#916 Integrate the SSH discovery feature into the server edit page, enabling users to populate server details from discovered servers.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a 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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@lollipopkit lollipopkit requested a review from Copilot September 17, 2025 17:55
Copy link

Copilot AI left a 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 l10n to libL10n throughout 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');
Copy link

Copilot AI Sep 17, 2025

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'.

Suggested change
dprint('Failed to import server ${server.name}: $e');
dprint('SSH Discovery: Failed to import server ${server.name}: $e');

Copilot uses AI. Check for mistakes.
if (_nameController.text.isEmpty) {
_nameController.text = server.ip;
}
context.showSnackBar('${libL10n.found} 1 server');
Copy link

Copilot AI Sep 17, 2025

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.

Suggested change
context.showSnackBar('${libL10n.found} 1 server');
context.showSnackBar(libL10n.foundServers(1));

Copilot uses AI. Check for mistakes.
dprint('Failed to import server ${server.name}: $e');
}
}
context.showSnackBar('Imported $imported ${l10n.servers}');
Copy link

Copilot AI Sep 17, 2025

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.

Suggested change
context.showSnackBar('Imported $imported ${l10n.servers}');
context.showSnackBar(l10n.importedServers(imported));

Copilot uses AI. Check for mistakes.
Comment on lines 281 to 282
try {
final socket = await Socket.connect(ip, SshDiscoveryService._sshPort, timeout: timeout);
Copy link

Copilot AI Sep 17, 2025

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.

Copilot uses AI. Check for mistakes.
@lollipopkit
Copy link
Owner Author

@claude review

@claude
Copy link

claude bot commented Sep 19, 2025

Claude finished @lollipopkit's task —— View job


Code Review: SSH Discovery Feature

Comprehensive review of the SSH server discovery implementation.

Review Progress

  • Read repository guidelines (CLAUDE.md)
  • Review core SSH discovery service implementation
  • Analyze data models and Freezed implementation
  • Review UI components and integration
  • Check localization changes
  • Evaluate security and best practices
  • Provide comprehensive feedback

Overall Assessment: ✅ APPROVED

This 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

  • Clean separation of concerns: Service layer (SshDiscoveryService), data models (SshDiscoveryResult), and UI components are properly separated
  • Follows repository patterns: Uses Freezed for immutable models, Riverpod for state management, and fl_lib utilities as specified in CLAUDE.md
  • Proper error handling: Network timeouts, process failures, and parsing errors are handled gracefully
  • Resource management: Sockets and stream subscriptions are properly cleaned up in finally blocks

Security & Network Scanning

  • Appropriate network scope: Limited to local ARP/NDP tables and /24-/30 subnets (prevents scanning large networks)
  • Proper address filtering: Excludes loopback, link-local, broadcast, and multicast addresses as suggested by Sourcery
  • Configurable timeouts: Prevents hanging connections (700ms default)
  • Concurrency limits: Prevents overwhelming target systems (128 default)
  • Platform-specific implementations: Different approaches for Linux (ip neigh) vs macOS (arp -an)

Data Models & Code Generation

  • Proper Freezed usage: All models use @freezed with immutability and JSON serialization
  • Configuration flexibility: SshDiscoveryConfig allows customization of timeouts, concurrency, and host enumeration limits
  • Selection state: isSelected field in SshDiscoveryResult supports UI multi-selection

UI Integration

  • Intuitive workflow: Discovery page → select servers → import with configurable defaults
  • Single vs bulk import: Smart handling of 1 server (auto-populate form) vs multiple (batch import dialog)
  • User confirmation: Prompts for username/key settings before bulk import
  • Progress indication: Loading states and discovery summary

Areas for Improvement

⚠️ Localization Consistency

Mixed usage of libL10n and l10n throughout the discovery UI. While repository guidelines prioritize libL10n, some strings use project-specific l10n.

Examples:

// lib/view/page/server/edit/actions.dart:25
context.showSnackBar('${libL10n.found} 1 ${l10n.server}');

// lib/view/page/server/edit/actions.dart:95
context.showSnackBar('Imported $imported ${l10n.servers}');

⚠️ Error Messages

Some error logging could be more descriptive:

// lib/view/page/server/edit/actions.dart:92
dprint('Failed to import server ${server.name}: $e');
// Could be: dprint('SSH Discovery: Failed to import server ${server.name}: $e');

⚠️ Platform Support

Windows platform isn't explicitly handled in SshDiscoveryService. While the service gracefully returns empty results, explicit platform checks could improve clarity.

Technical Review

Network Discovery Implementation

The multi-phase discovery approach is well-designed:

  1. ARP/NDP tables: Gets active neighbors efficiently
  2. Subnet enumeration: Scans local networks systematically
  3. mDNS/Bonjour: Optional service discovery (macOS: dns-sd, Linux: avahi-browse)

Concurrency & Performance

  • Custom _Semaphore implementation properly limits concurrent connections
  • Configurable host enumeration limit (4096 default) prevents excessive scanning
  • Timeout handling prevents blocking on unresponsive hosts

SSH Banner Detection

The SSH probing implementation correctly:

  • Connects to port 22 and reads the banner
  • Handles partial/malformed data gracefully
  • Times out appropriately to avoid hanging

Code Quality Observations

Follows Repository Guidelines

  • Uses fl_lib components (CustomAppBar, Input, Btnx.cancelOk)
  • Proper extension usage for UI organization (extension _Actions, extension _Widgets)
  • No code formatting issues (respects repository's "never format" rule)
  • Generated files properly included (*.g.dart, *.freezed.dart)

Error Handling

  • Network parsing includes try/catch with informative logging
  • Socket operations use try/finally for cleanup
  • Process execution timeouts prevent hanging

Sourcery Review Comments Status

The commit 22071d2 addressed all major Sourcery concerns:

  • ✅ Broadcast/multicast filtering implemented
  • ✅ Host enumeration limit made configurable
  • ✅ ARP parsing includes match counting and warnings
  • ✅ ifconfig parsing wrapped with error handling
  • ✅ Socket cleanup in finally blocks
  • ✅ StreamSubscription cancellation guaranteed

Recommendation

APPROVE - This is a solid implementation that adds valuable functionality while maintaining code quality and security standards. The few minor issues noted above don't impact the core functionality and can be addressed in follow-up work if desired.

The feature successfully:

  • Discovers SSH servers safely within local network boundaries
  • Provides an intuitive UI workflow
  • Handles errors gracefully
  • Follows repository architectural patterns
  • Includes comprehensive localization

@lollipopkit lollipopkit merged commit f68c4a8 into main Sep 19, 2025
2 checks passed
@lollipopkit lollipopkit deleted the lollipopkit/issue916 branch September 19, 2025 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: discover local ssh server

2 participants