mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 15:24:35 +01:00
feat: discover local ssh server (#921)
This commit is contained in:
408
lib/core/service/ssh_discovery.dart
Normal file
408
lib/core/service/ssh_discovery.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:server_box/data/model/server/discovery_result.dart';
|
||||
|
||||
class SshDiscoveryService {
|
||||
static const _sshPort = 22;
|
||||
|
||||
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: loopback, link-local, 0.0.0.0, broadcast, multicast
|
||||
candidates.removeWhere(
|
||||
(a) => a.isLoopback || a.isLinkLocal || a.address == '0.0.0.0' || _isBroadcastOrMulticast(a),
|
||||
);
|
||||
|
||||
// 4) Concurrent SSH port scanning
|
||||
final scanner = _Scanner(
|
||||
timeout: Duration(milliseconds: config.timeoutMs),
|
||||
maxConcurrency: config.maxConcurrency,
|
||||
);
|
||||
|
||||
final results = await scanner.scan(candidates.toList(growable: false));
|
||||
results.sort((a, b) => a.addr.address.compareTo(b.addr.address));
|
||||
|
||||
final discoveryResults = results
|
||||
.map((r) => SshDiscoveryResult(ip: r.addr.address, port: _sshPort, banner: r.banner?.trim()))
|
||||
.toList();
|
||||
|
||||
return SshDiscoveryReport(
|
||||
generatedAt: DateTime.now().toIso8601String(),
|
||||
durationMs: DateTime.now().difference(t0).inMilliseconds,
|
||||
count: discoveryResults.length,
|
||||
items: discoveryResults,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String?> _run(String exe, List<String> args, {Duration? timeout}) async {
|
||||
try {
|
||||
final p = await Process.start(exe, args, runInShell: false);
|
||||
final out = await p.stdout
|
||||
.transform(utf8.decoder)
|
||||
.join()
|
||||
.timeout(
|
||||
timeout ?? const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
p.kill();
|
||||
return '';
|
||||
},
|
||||
);
|
||||
final code = await p.exitCode;
|
||||
if (code == 0) return out;
|
||||
// Some tools return non-zero but still have useful output
|
||||
if (out.trim().isNotEmpty) return out;
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool get _isLinux => Platform.isLinux;
|
||||
static bool get _isMac => Platform.isMacOS;
|
||||
|
||||
static Future<Set<InternetAddress>> _neighborsIPv4() async {
|
||||
final set = <InternetAddress>{};
|
||||
if (_isLinux) {
|
||||
final s = await _run('ip', ['neigh']);
|
||||
if (s != null) {
|
||||
for (final line in const LineSplitter().convert(s)) {
|
||||
final tok = line.split(RegExp(r'\s+'));
|
||||
if (tok.isNotEmpty) {
|
||||
final ip = tok[0];
|
||||
if (InternetAddress.tryParse(ip)?.type == InternetAddressType.IPv4) {
|
||||
set.add(InternetAddress(ip));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (_isMac) {
|
||||
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) {
|
||||
lprint(
|
||||
'[ssh_discovery] Warning: No ARP entries parsed on macOS. Output may be unexpected or localized. Output sample: ${s.length > 100 ? '${s.substring(0, 100)}...' : s}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
static Future<Set<InternetAddress>> _neighborsIPv6() async {
|
||||
final set = <InternetAddress>{};
|
||||
if (_isLinux) {
|
||||
final s = await _run('ip', ['-6', 'neigh']);
|
||||
if (s != null) {
|
||||
for (final line in const LineSplitter().convert(s)) {
|
||||
final ip = line.split(RegExp(r'\s+')).firstOrNull;
|
||||
if (ip != null && InternetAddress.tryParse(ip)?.type == InternetAddressType.IPv6) {
|
||||
set.add(InternetAddress(ip));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (_isMac) {
|
||||
final s = await _run('/usr/sbin/ndp', ['-a']);
|
||||
if (s != null) {
|
||||
for (final line in const LineSplitter().convert(s)) {
|
||||
final ip = line.trim().split(RegExp(r'\s+')).firstOrNull;
|
||||
if (ip != null && InternetAddress.tryParse(ip)?.type == InternetAddressType.IPv6) {
|
||||
set.add(InternetAddress(ip));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
static Future<List<_Cidr>> _localIPv4Cidrs() async {
|
||||
final res = <_Cidr>[];
|
||||
if (_isLinux) {
|
||||
final s = await _run('ip', ['-o', '-4', 'addr', 'show', 'scope', 'global']);
|
||||
if (s != null) {
|
||||
for (final line in const LineSplitter().convert(s)) {
|
||||
final m = RegExp(r'inet\s+(\d+\.\d+\.\d+\.\d+)\/(\d+)').firstMatch(line);
|
||||
if (m != null) {
|
||||
final ip = InternetAddress(m.group(1)!);
|
||||
final prefix = int.parse(m.group(2)!);
|
||||
final mask = _prefixToMask(prefix);
|
||||
final net = _networkAddress(ip, mask);
|
||||
final brd = _broadcastAddress(ip, mask);
|
||||
res.add(_Cidr(ip, prefix, mask, net, brd));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (_isMac) {
|
||||
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 unexpected format but continue processing other lines
|
||||
lprint('[ssh_discovery] Warning: Unexpected ifconfig line format: $line');
|
||||
continue;
|
||||
}
|
||||
final ip = InternetAddress(ipm.group(1)!);
|
||||
final hexMask = int.parse(ipm.group(2)!, radix: 16);
|
||||
final dotted =
|
||||
'${(hexMask >> 24) & 0xff}.${(hexMask >> 16) & 0xff}.${(hexMask >> 8) & 0xff}.${hexMask & 0xff}';
|
||||
final mask = InternetAddress(dotted);
|
||||
final prefix = _maskToPrefix(mask.address);
|
||||
final net = _networkAddress(ip, mask);
|
||||
final brd = InternetAddress(ipm.group(3) ?? _broadcastAddress(ip, mask).address);
|
||||
res.add(_Cidr(ip, prefix, mask, net, brd));
|
||||
} catch (e) {
|
||||
lprint('[ssh_discovery] Error parsing ifconfig output: $e, line: $line');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
static bool _isBroadcastOrMulticast(InternetAddress 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;
|
||||
}
|
||||
|
||||
static Future<Set<InternetAddress>> _mdnsSshCandidates() async {
|
||||
final set = <InternetAddress>{};
|
||||
if (_isMac) {
|
||||
try {
|
||||
final proc = await Process.start('/usr/bin/dns-sd', ['-B', '_ssh._tcp']);
|
||||
final lines = <String>[];
|
||||
final subscription = proc.stdout
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(lines.add);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
proc.kill();
|
||||
await subscription.cancel();
|
||||
|
||||
for (final l in lines) {
|
||||
final m = RegExp(r'Add\s+\d+\s+(\S+)\.\s+_ssh\._tcp\.').firstMatch(l);
|
||||
if (m != null) {
|
||||
final name = m.group(1)!;
|
||||
final det = await _run('/usr/bin/dns-sd', [
|
||||
'-L',
|
||||
name,
|
||||
'_ssh._tcp',
|
||||
'local.',
|
||||
], timeout: const Duration(seconds: 3));
|
||||
if (det != null) {
|
||||
for (final ip in RegExp(
|
||||
r'Address\s*=\s*([0-9a-fA-F:\.]+)',
|
||||
).allMatches(det).map((e) => e.group(1)!)) {
|
||||
final parsed = InternetAddress.tryParse(ip);
|
||||
if (parsed != null) set.add(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (_isLinux) {
|
||||
final s = await _run('/usr/bin/avahi-browse', ['-rat', '_ssh._tcp']);
|
||||
if (s != null) {
|
||||
for (final ip in RegExp(
|
||||
r'address = \[(.*?)\]',
|
||||
).allMatches(s).map((m) => m.group(1)!).where((e) => e.isNotEmpty)) {
|
||||
final parsed = InternetAddress.tryParse(ip);
|
||||
if (parsed != null) set.add(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
class _Cidr {
|
||||
final InternetAddress ip;
|
||||
final int prefix;
|
||||
final InternetAddress netmask;
|
||||
final InternetAddress network;
|
||||
final InternetAddress broadcast;
|
||||
|
||||
_Cidr(this.ip, this.prefix, this.netmask, this.network, this.broadcast);
|
||||
|
||||
Iterable<InternetAddress> enumerateHosts({int? limit}) sync* {
|
||||
final n = _ipv4ToInt(network.address);
|
||||
final b = _ipv4ToInt(broadcast.address);
|
||||
int emitted = 0;
|
||||
for (int v = n + 1; v <= b - 1; v++) {
|
||||
if (limit != null && emitted >= limit) break;
|
||||
emitted++;
|
||||
yield InternetAddress(_intToIPv4(v));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '${network.address}/$prefix';
|
||||
}
|
||||
|
||||
class _ScanResult {
|
||||
final InternetAddress addr;
|
||||
final String? banner;
|
||||
_ScanResult(this.addr, this.banner);
|
||||
}
|
||||
|
||||
class _Scanner {
|
||||
final Duration timeout;
|
||||
final int maxConcurrency;
|
||||
_Scanner({required this.timeout, required this.maxConcurrency});
|
||||
|
||||
Future<List<_ScanResult>> scan(List<InternetAddress> addrs) async {
|
||||
final sem = _Semaphore(maxConcurrency);
|
||||
final futures = <Future<_ScanResult?>>[];
|
||||
for (final a in addrs) {
|
||||
futures.add(_guarded(sem, () => _probeSsh(a)));
|
||||
}
|
||||
final out = await Future.wait(futures);
|
||||
return out.whereType<_ScanResult>().toList();
|
||||
}
|
||||
|
||||
Future<_ScanResult?> _probeSsh(InternetAddress ip) async {
|
||||
Socket? socket;
|
||||
StreamSubscription? sub;
|
||||
try {
|
||||
socket = await Socket.connect(ip, SshDiscoveryService._sshPort, timeout: timeout);
|
||||
socket.timeout(timeout);
|
||||
final c = Completer<String?>();
|
||||
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);
|
||||
return _ScanResult(ip, banner);
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
sub?.cancel();
|
||||
socket?.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Semaphore {
|
||||
int _permits;
|
||||
final Queue<Completer<void>> _q = Queue();
|
||||
_Semaphore(this._permits);
|
||||
|
||||
Future<T> withPermit<T>(Future<T> Function() fn) async {
|
||||
if (_permits > 0) {
|
||||
_permits--;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
_permits++;
|
||||
if (_q.isNotEmpty) _q.removeFirst().complete();
|
||||
}
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_q.add(c);
|
||||
await c.future;
|
||||
return withPermit(fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _guarded<T>(_Semaphore sem, Future<T> Function() fn) => sem.withPermit(fn);
|
||||
|
||||
// IPv4 utilities
|
||||
|
||||
int _ipv4ToInt(String ip) {
|
||||
final p = ip.split('.').map(int.parse).toList();
|
||||
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
|
||||
}
|
||||
|
||||
String _intToIPv4(int v) => '${(v >> 24) & 0xff}.${(v >> 16) & 0xff}.${(v >> 8) & 0xff}.${v & 0xff}';
|
||||
|
||||
InternetAddress _prefixToMask(int prefix) {
|
||||
final mask = prefix == 0 ? 0 : 0xffffffff << (32 - prefix);
|
||||
return InternetAddress(_intToIPv4(mask & 0xffffffff));
|
||||
}
|
||||
|
||||
int _maskToPrefix(String mask) {
|
||||
final v = _ipv4ToInt(mask);
|
||||
int c = 0;
|
||||
for (int i = 31; i >= 0; i--) {
|
||||
if ((v & (1 << i)) != 0) {
|
||||
c++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
InternetAddress _networkAddress(InternetAddress ip, InternetAddress mask) {
|
||||
final v = _ipv4ToInt(ip.address) & _ipv4ToInt(mask.address);
|
||||
return InternetAddress(_intToIPv4(v));
|
||||
}
|
||||
|
||||
InternetAddress _broadcastAddress(InternetAddress ip, InternetAddress mask) {
|
||||
final n = _ipv4ToInt(ip.address) & _ipv4ToInt(mask.address);
|
||||
final b = n | (~_ipv4ToInt(mask.address) & 0xffffffff);
|
||||
return InternetAddress(_intToIPv4(b));
|
||||
}
|
||||
Reference in New Issue
Block a user