diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 82b6e4c..8a195c2 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -8,10 +8,8 @@ name: Dart on: push: branches: [ "main" ] - paths: ['network_tools/**'] pull_request: branches: [ "main", "dev" ] - paths: ['network_tools/**'] concurrency: group: ${{ github.head_ref || github.run_id }} @@ -35,7 +33,6 @@ jobs: - name: Install dependencies run: dart pub get - working-directory: ./network_tools # Uncomment this step to verify the use of 'dart format' on each commit. # - name: Verify formatting @@ -44,11 +41,9 @@ jobs: # Consider passing '--fatal-infos' for slightly stricter analysis. - name: Analyze project source run: dart analyze --fatal-infos - working-directory: ./network_tools # Your project will need to have tests in test/ and a dependency on # package:test for this step to succeed. Note that Flutter projects will # want to change this to 'flutter test'. - name: Run tests - run: dart test - working-directory: ./network_tools \ No newline at end of file + run: dart test \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc527bd..9d8bd32 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,25 +17,21 @@ jobs: # https://github.com/dart-lang/setup-dart/blob/main/README.md - uses: dart-lang/setup-dart@v1.4 # - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 + + - name: Install dependencies + run: dart pub get - name: Analyze project source run: dart analyze --fatal-infos - working-directory: ./network_tools - name: Run tests run: dart test --coverage="coverage" - working-directory: ./network_tools - - - name: Install dependencies - run: dart pub get - working-directory: ./network_tools - name: Install coverage tools run: dart pub global activate coverage - name: Format Coverage run: $HOME/.pub-cache/bin/format_coverage --lcov --in=coverage --out=coverage/coverage.lcov --report-on=lib - working-directory: ./network_tools - name: Setup Pub Credentials shell: bash @@ -49,11 +45,9 @@ jobs: - name: Check Publish Warnings run: dart pub publish --dry-run - working-directory: ./network_tools - name: Publish Package run: dart pub publish -f - working-directory: ./network_tools - name: Upload Coverage to CodeCov uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index 3f45d2d..5fb1835 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ .packages .pub-cache/ .pub/ -build/ +**/coverage/ +**/build/ diff --git a/network_tools_flutter/.metadata b/.metadata similarity index 82% rename from network_tools_flutter/.metadata rename to .metadata index e7011f6..cb69e6e 100644 --- a/network_tools_flutter/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + revision: 02c026b03cd31dd3f867e5faeb7e104cce174c5f channel: stable project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ffd572 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Change Log + +## 3.2.2 + +1. Supporting changes for network_tools_flutter + +## 3.2.1 + +1. Now GHA runs tests against 3 platforms windows, ubuntu, macos +2. Code coverage is fixed so that it's visible in codecov portal. + +## 3.2.0 + +1. getAllPingableDevicesAsync runs inside isolate to find all devices in a network. +2. Bug fixes and improvements + +## 3.1.0 + +Web Support and bug fixes for mDns devices giving error in getting IP + +## 3.0.0+3 + +Refactoring for field names + +## 3.0.0+2 + +This release is only meant to test Github Actions for Dart Package publishing. It's same as v3.0.0. Nothing changed. + +## 3.0.0+1 + +This release is only meant to test Github Actions for Dart Package publishing. It's same as v3.0.0. Nothing changed. + +## 3.0.0 + +**Breaking change** Change most of the methods names + + * HostScanner.discover to HostScanner.getAllPingableDevices + * HostScanner.discoverPort to HostScanner.scanDevicesForSinglePort + * PortScanner.discover to PortScanner.scanPortsForSingleDevice + +Ip field in ActiveHost is now InternetAddress type instead of string which improve handling of IPv6. + +ActiveHost now contains host name of the address if exist. + +Better naming of methods + +Bug fixes and improvements + +## 2.1.0 + +Added partly support for searching mdns devices. + +## 2.0.0 + +**Breaking change** Bump minimum dart version to 2.17.0. + +Updated dart_ping package version to 7.0.1. + +Updated test package version to 1.21.4. + +## 1.0.8 + +Fixes + +Add logs using logging package #13. + +Even more points in pub.dev page #20. + +## 1.0.7 + +Fixed + +Saving the response time from each device #9. + +Example crash on Windows #14. + +Bump package version #15. + +## 0.0.6 + +Resolved issue #9 and #10. + +## 0.0.5 + +Resolved issue #1. + +## 0.0.4 + +Single and Custom Port Scan added. + +## 0.0.3 + +Subnet and Port range added. + +## 0.0.2 + +Added example and followed pub conventions. + +## 0.0.1 + +PortScanner and HostScanner. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31efaf2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, Elliot Alderson +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..fa699ff --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,35 @@ +# Defines a default set of lint rules enforced for +# projects at Google. For details and rationale, +# see https://github.com/dart-lang/pedantic#enabled-lints. +#include: package:pedantic/analysis_options.yaml + +# lint analysis +include: package:lint/analysis_options.yaml + + +analyzer: + errors: + missing_required_param: error + missing_return: error + must_be_immutable: error + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/*.pb.dart" + - "**/*.pbenum.dart" + - "**/*.pbgrpc.dart" + - "**/*.pbjson.dart" + - "**/*.gr.dart" + - "**/*.md" + - "example/**" + +linter: + rules: + # Use parameter order as in json response + always_put_required_named_parameters_first: true + + avoid_classes_with_only_static_members: false + + sort_constructors_first: true + + avoid_relative_lib_imports: false diff --git a/example/host_scan.dart b/example/host_scan.dart new file mode 100644 index 0000000..837cf49 --- /dev/null +++ b/example/host_scan.dart @@ -0,0 +1,40 @@ +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import 'package:network_tools/network_tools.dart'; + +void main() { + Logger.root.level = Level.FINE; + Logger.root.onRecord.listen((record) { + print( + '${DateFormat.Hms().format(record.time)}: ${record.level.name}: ${record.loggerName}: ${record.message}', + ); + }); + final log = Logger("host_scan_example"); + + const String address = '192.168.1.1'; + // or You can also get address using network_info_plus package + // final String? address = await (NetworkInfo().getWifiIP()); + final String subnet = address.substring(0, address.lastIndexOf('.')); + + // You can set [firstHostId] and scan will start from this host in the network. + // Similarly set [lastHostId] and scan will end at this host in the network. + final stream = HostScanner.getAllPingableDevicesAsync( + subnet, + // firstHostId: 1, + // lastHostId: 254, + progressCallback: (progress) { + log.finer('Progress for host discovery : $progress'); + }, + ); + + stream.listen( + (ActiveHost host) async { + //Same host can be emitted multiple times + //Use Set instead of List + log.fine('Found device: ${await host.toStringFull()}'); + }, + onDone: () { + log.fine('Scan completed'); + }, + ); // Don't forget to cancel the stream when not in use. +} diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 0000000..09e0de3 --- /dev/null +++ b/example/main.dart @@ -0,0 +1,5 @@ +void main() { + print("Run host scan : 'dart example/host_scan.dart'"); + print("Run port scan : 'dart example/port_scan.dart'"); + print("Run mdns scan : 'dart example/mdns_scan.dart'"); +} diff --git a/example/mdns_scan.dart b/example/mdns_scan.dart new file mode 100644 index 0000000..5936af1 --- /dev/null +++ b/example/mdns_scan.dart @@ -0,0 +1,10 @@ +import 'package:network_tools/network_tools.dart'; + +Future main() async { + for (final ActiveHost activeHost in await MdnsScanner.searchMdnsDevices()) { + final MdnsInfo? mdnsInfo = await activeHost.mdnsInfo; + print( + 'Address: ${activeHost.address}, Port: ${mdnsInfo!.mdnsPort}, ServiceType: ${mdnsInfo.mdnsServiceType}, MdnsName: ${mdnsInfo.getOnlyTheStartOfMdnsName()}, Mdns Device Name: ${mdnsInfo.mdnsSrvTarget}', + ); + } +} diff --git a/example/port_scan.dart b/example/port_scan.dart new file mode 100644 index 0000000..f13edf5 --- /dev/null +++ b/example/port_scan.dart @@ -0,0 +1,66 @@ +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import 'package:network_tools/network_tools.dart'; + +void main() { + Logger.root.level = Level.FINE; + Logger.root.onRecord.listen((record) { + print( + '${DateFormat.Hms().format(record.time)}: ${record.level.name}: ${record.loggerName}: ${record.message}', + ); + }); + + const String address = '192.168.1.1'; + // or You can also get address using network_info_plus package + // final String? address = await (NetworkInfo().getWifiIP()); + final String subnet = address.substring(0, address.lastIndexOf('.')); + + // [New] Scan for a single open port in a subnet + // You can set [firstHostId] and scan will start from this host in the network. + // Similarly set [lastHostId] and scan will end at this host in the network. + final stream2 = HostScanner.scanDevicesForSinglePort( + subnet, + 53, + // firstHostId: 1, + // lastHostId: 254, + progressCallback: (progress) { + log.finer('Progress for port discovery on host : $progress'); + }, + ); + + stream2.listen( + (activeHost) { + final OpenPort deviceWithOpenPort = activeHost.openPort[0]; + if (deviceWithOpenPort.isOpen) { + log.fine( + 'Found open port: ${deviceWithOpenPort.port} on ${activeHost.address}', + ); + } + }, + onDone: () { + log.fine('Port Scan completed'); + }, + ); // Don't forget to cancel the stream when not in use. + + const String target = '192.168.1.1'; + PortScanner.scanPortsForSingleDevice( + target, + // Scan will start from this port. + // startPort: 1, + endPort: 9400, + progressCallback: (progress) { + log.finer('Progress for port discovery : $progress'); + }, + ).listen( + (activeHost) { + final OpenPort deviceWithOpenPort = activeHost.openPort[0]; + + if (deviceWithOpenPort.isOpen) { + log.fine('Found open port: ${deviceWithOpenPort.port}'); + } + }, + onDone: () { + log.fine('Port Scan from 1 to 9400 completed'); + }, + ); +} diff --git a/lib/network_tools.dart b/lib/network_tools.dart new file mode 100644 index 0000000..345e7a8 --- /dev/null +++ b/lib/network_tools.dart @@ -0,0 +1,12 @@ +/// Network tools base library +library network_tools; + +//TODO: add dartdocs +export 'src/host_scanner.dart'; +export 'src/mdns_scanner/mdns_scanner.dart'; +export 'src/models/active_host.dart'; +export 'src/models/callbacks.dart'; +export 'src/models/mdns_info.dart'; +export 'src/models/open_port.dart'; +export 'src/models/sendable_active_host.dart'; +export 'src/port_scanner.dart'; diff --git a/lib/src/host_scanner.dart b/lib/src/host_scanner.dart new file mode 100644 index 0000000..776d6ef --- /dev/null +++ b/lib/src/host_scanner.dart @@ -0,0 +1,303 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:dart_ping/dart_ping.dart'; +import 'package:isolate_manager/isolate_manager.dart'; +import 'package:network_tools/src/models/active_host.dart'; +import 'package:network_tools/src/models/callbacks.dart'; +import 'package:network_tools/src/models/sendable_active_host.dart'; +import 'package:network_tools/src/network_tools_utils.dart'; +import 'package:network_tools/src/port_scanner.dart'; + +/// Scans for all hosts in a subnet. +class HostScanner { + /// Devices scan will start from this integer Id + static const int defaultFirstHostId = 1; + + /// Devices scan will stop at this integer id + static const int defaultLastHostId = 254; + + /// Scans for all hosts in a particular subnet (e.g., 192.168.1.0/24) + /// Set maxHost to higher value if you are not getting results. + /// It won't firstHostId again unless previous scan is completed due to heavy + /// resource consumption. + /// [resultsInAddressAscendingOrder] = false will return results faster but not in + /// ascending order and without [progressCallback]. + static Stream getAllPingableDevices( + String subnet, { + int firstHostId = defaultFirstHostId, + int lastHostId = defaultLastHostId, + int timeoutInSeconds = 1, + ProgressCallback? progressCallback, + bool resultsInAddressAscendingOrder = true, + }) async* { + final stream = getAllSendablePingableDevices(subnet, firstHostId: firstHostId, lastHostId: lastHostId, + timeoutInSeconds: timeoutInSeconds, progressCallback: progressCallback, resultsInAddressAscendingOrder: resultsInAddressAscendingOrder,); + await for (final sendableActiveHost in stream){ + final activeHost = ActiveHost.fromSendableActiveHost(sendableActiveHost: sendableActiveHost); + + await activeHost.resolveInfo(); + + yield activeHost; + } + } + /// Same as [getAllPingableDevices] but can be called or run inside isolate. + static Stream getAllSendablePingableDevices( + String subnet, { + int firstHostId = defaultFirstHostId, + int lastHostId = defaultLastHostId, + int timeoutInSeconds = 1, + ProgressCallback? progressCallback, + bool resultsInAddressAscendingOrder = true, + }) async* { + final int lastValidSubnet = + validateAndGetLastValidSubnet(subnet, firstHostId, lastHostId); + final List> activeHostsFuture = []; + final StreamController activeHostsController = + StreamController(); + + for (int i = firstHostId; i <= lastValidSubnet; i++) { + activeHostsFuture.add( + _getHostFromPing( + activeHostsController: activeHostsController, + host: '$subnet.$i', + i: i, + timeoutInSeconds: timeoutInSeconds, + ), + ); + } + + if (!resultsInAddressAscendingOrder) { + yield* activeHostsController.stream; + } + + int i = 0; + for (final Future host in activeHostsFuture) { + i++; + final SendableActiveHost? tempHost = await host; + + progressCallback + ?.call((i - firstHostId) * 100 / (lastValidSubnet - firstHostId)); + + if (tempHost == null) { + continue; + } + yield tempHost; + } + } + static Future _getHostFromPing({ + required String host, + required int i, + required StreamController activeHostsController, + int timeoutInSeconds = 1, + }) async { + await for (final PingData pingData + in Ping(host, count: 1, timeout: timeoutInSeconds).stream) { + final PingResponse? response = pingData.response; + if (response != null) { + final Duration? time = response.time; + if (time != null) { + final tempSendableActivateHost = SendableActiveHost(host, pingData); + activeHostsController.add(tempSendableActivateHost); + return tempSendableActivateHost; + } + } + } + return null; + } + + + static int validateAndGetLastValidSubnet( + String subnet, + int firstHostId, + int lastHostId, + ) { + final int maxEnd = maxHost; + if (firstHostId > lastHostId || + firstHostId < defaultFirstHostId || + lastHostId < defaultFirstHostId || + firstHostId > maxEnd || + lastHostId > maxEnd) { + throw 'Invalid subnet range or firstHostId < lastHostId is not true'; + } + return min(lastHostId, maxEnd); + } + + /// Works same as [getAllPingableDevices] but does everything inside + /// isolate out of the box. + static Stream getAllPingableDevicesAsync( + String subnet, { + int firstHostId = defaultFirstHostId, + int lastHostId = defaultLastHostId, + int timeoutInSeconds = 1, + ProgressCallback? progressCallback, + bool resultsInAddressAscendingOrder = true, + }) async* { + + const int scanRangeForIsolate = 51; + final int lastValidSubnet = + validateAndGetLastValidSubnet(subnet, firstHostId, lastHostId); + for (int i = firstHostId; + i <= lastValidSubnet; + i += scanRangeForIsolate + 1) { + final isolateManager = + IsolateManager.createOwnIsolate(_startSearchingDevices); + final limit = min(i + scanRangeForIsolate, lastValidSubnet); + log.fine('Scanning from $i to $limit'); + isolateManager.sendMessage([ + subnet, + i.toString(), + limit.toString(), + timeoutInSeconds.toString(), + resultsInAddressAscendingOrder.toString(), + ]); + await for (final message in isolateManager.onMessage.asBroadcastStream()){ + if (message is SendableActiveHost) { + progressCallback + ?.call((i - firstHostId) * 100 / (lastValidSubnet - firstHostId)); + + final activeHostFound = ActiveHost.fromSendableActiveHost(sendableActiveHost: message); + await activeHostFound.resolveInfo(); + yield activeHostFound; + } else if (message is String && message == 'Done') { + isolateManager.stop(); + } + } + } + } + + /// Will search devices in the network inside new isolate + @pragma('vm:entry-point') + static Future _startSearchingDevices(dynamic params) async { + final channel = IsolateManagerController(params); + channel.onIsolateMessage.listen((message) async { + List paramsListString = []; + if (message is List) { + paramsListString = message; + } else { + return; + } + + final String subnetIsolate = paramsListString[0]; + final int firstSubnetIsolate = int.parse(paramsListString[1]); + final int lastSubnetIsolate = int.parse(paramsListString[2]); + final int timeoutInSeconds = int.parse(paramsListString[3]); + final bool resultsInAddressAscendingOrder = paramsListString[4] == "true"; + + /// Will contain all the hosts that got discovered in the network, will + /// be use inorder to cancel on dispose of the page. + final Stream hostsDiscoveredInNetwork = + HostScanner.getAllSendablePingableDevices( + subnetIsolate, + firstHostId: firstSubnetIsolate, + lastHostId: lastSubnetIsolate, + timeoutInSeconds: timeoutInSeconds, + resultsInAddressAscendingOrder: resultsInAddressAscendingOrder, + ); + + await for (final SendableActiveHost activeHostFound in hostsDiscoveredInNetwork) { + channel.sendResult(activeHostFound); + } + channel.sendResult('Done'); + }); + } + + /// Scans for all hosts that have the specific port that was given. + /// [resultsInAddressAscendingOrder] = false will return results faster but not in + /// ascending order and without [progressCallback]. + static Stream scanDevicesForSinglePort( + String subnet, + int port, { + int firstHostId = defaultFirstHostId, + int lastHostId = defaultLastHostId, + Duration timeout = const Duration(milliseconds: 2000), + ProgressCallback? progressCallback, + bool resultsInAddressAscendingOrder = true, + }) async* { + final int lastValidSubnet = + validateAndGetLastValidSubnet(subnet, firstHostId, lastHostId); + final List> activeHostOpenPortList = []; + final StreamController activeHostsController = + StreamController(); + + for (int i = firstHostId; i <= lastValidSubnet; i++) { + final host = '$subnet.$i'; + activeHostOpenPortList.add( + PortScanner.connectToPort( + address: host, + port: port, + timeout: timeout, + activeHostsController: activeHostsController, + ), + ); + } + + if (!resultsInAddressAscendingOrder) { + yield* activeHostsController.stream; + } + + int counter = firstHostId; + for (final Future openPortActiveHostFuture + in activeHostOpenPortList) { + final ActiveHost? activeHost = await openPortActiveHostFuture; + if (activeHost != null) { + yield activeHost; + } + progressCallback?.call( + (counter - firstHostId) * 100 / (lastValidSubnet - firstHostId), + ); + counter++; + } + } + + /// Defines total number of subnets in class A network + static const classASubnets = 16777216; + + /// Defines total number of subnets in class B network + static const classBSubnets = 65536; + + /// Defines total number of subnets in class C network + static const classCSubnets = 256; + + /// Minimum value of first octet in IPv4 address used by getMaxHost + static const int minNetworkId = 1; + + /// Maximum value of first octect in IPv4 address used by getMaxHost + static const int maxNetworkId = 223; + + /// returns the max number of hosts a subnet can have excluding network Id and broadcast Id + @Deprecated( + "Implementation is wrong, since we only append in last octet, max host can only be 254. Use maxHost getter", + ) + static int getMaxHost(String subnet) { + if (subnet.isEmpty) { + throw ArgumentError('Invalid subnet address, address can not be empty.'); + } + final List firstOctetStr = subnet.split('.'); + if (firstOctetStr.isEmpty) { + throw ArgumentError( + 'Invalid subnet address, address should be in IPv4 format x.x.x', + ); + } + + final int firstOctet = int.parse(firstOctetStr[0]); + + if (firstOctet >= minNetworkId && firstOctet < 128) { + return classASubnets; + } else if (firstOctet >= 128 && firstOctet < 192) { + return classBSubnets; + } else if (firstOctet >= 192 && firstOctet <= maxNetworkId) { + return classCSubnets; + } + // Out of range for first octet + throw RangeError.range( + firstOctet, + minNetworkId, + maxNetworkId, + 'subnet', + 'Out of range for first octet', + ); + } + + static int get maxHost => defaultLastHostId; +} diff --git a/lib/src/mdns_scanner/get_srv_list_by_os/srv_list.dart b/lib/src/mdns_scanner/get_srv_list_by_os/srv_list.dart new file mode 100644 index 0000000..d7d3207 --- /dev/null +++ b/lib/src/mdns_scanner/get_srv_list_by_os/srv_list.dart @@ -0,0 +1,21 @@ +import 'package:network_tools/src/mdns_scanner/get_srv_list_by_os/srv_list_linux.dart'; +import 'package:universal_io/io.dart'; + +/// This class is common interface for executing functions on different os +class SrvList { + /// Will get the srv record in the local network. + static Future?> getSrvRecordList() async { + if (Platform.isLinux) { + return SrvListLinux.getSrvRecordList(); + } + // else if (Platform.isMacOS){ + // // I think the command should be dns-sd so + // // dns-sd -B _services._dns-sd._udp local. + // // and + // // dns-sd -B _ipp._tcp local. + // } + + // Get srv record list is not supported on this os + return []; + } +} diff --git a/lib/src/mdns_scanner/get_srv_list_by_os/srv_list_linux.dart b/lib/src/mdns_scanner/get_srv_list_by_os/srv_list_linux.dart new file mode 100644 index 0000000..9b463c2 --- /dev/null +++ b/lib/src/mdns_scanner/get_srv_list_by_os/srv_list_linux.dart @@ -0,0 +1,112 @@ +import 'dart:collection'; + +import 'package:network_tools/src/network_tools_utils.dart'; +import 'package:process_run/shell.dart'; + +class SrvListLinux { + static Future?> getSrvRecordList() async { + final HashSet srvList = HashSet(); + + try { + srvList.addAll(await runAvahiBrowseCommand()); + srvList.addAll(await runMdnsScanCommand()); + } catch (e) { + log.severe('Error:\n$e'); + } + return srvList.toList(); + } + + /// Will try to get results from avahi-browse, it is not installed by default + /// on all Linux machines + static Future> runAvahiBrowseCommand() async { + final shell = Shell(verbose: false); + + final List srvListAvahi = []; + + List resultForEachLine = []; + try { + await shell.run( + ''' +timeout 2s avahi-browse --all -p +''', + ).onError((ShellException error, stackTrace) { + // The command should return error as we are killing it with the command timeout + + final String? resultStderr = error.result?.stderr.toString(); + if (resultStderr != null && + resultStderr.contains('No such file or directory')) { + log.fine( + 'You can make the mdns process better by installing `avahi-browse`', + ); + return []; + } + final String? resultStdout = error.result?.stdout.toString(); + if (resultStdout == null) { + return []; + } + resultForEachLine = resultStdout.split('\n'); + + return []; + }); + + for (final String resultLine in resultForEachLine) { + final List lineSeparated = resultLine.split(';'); + if (lineSeparated.length >= 6) { + final String srvString = lineSeparated[lineSeparated.length - 2]; + if (!srvString.contains(' ')) { + srvListAvahi.add(srvString); + } + } + } + } catch (e) { + log.severe('Error getting info from avahi-browse\n$e'); + } + return srvListAvahi; + } + + /// Will try to get results from mdns-scan, it is not installed by default + /// on all Linux machines + static Future> runMdnsScanCommand() async { + final shell = Shell(verbose: false); + + final List srvListMdnsScan = []; + + List resultForEachLine = []; + try { + await shell.run( + ''' +timeout 2s mdns-scan +''', + ).onError((ShellException error, stackTrace) { + // The command should return error as we are killing it with the command timeout + + final String? resultStderr = error.result?.stderr.toString(); + + if (resultStderr == null || + (resultStderr.contains('No such file or directory'))) { + log.fine( + 'You can make the mdns process better by installing `mdns-scan`', + ); + return []; + } + resultForEachLine = resultStderr.split('\n'); + + return []; + }); + + for (final String resultLine in resultForEachLine) { + final List lineSeparated = resultLine.split('.'); + if (lineSeparated.length >= 4) { + final String srvString = + '${lineSeparated[lineSeparated.length - 3]}.${lineSeparated[lineSeparated.length - 2]}'; + if (!srvString.contains(' ') && srvString != '.') { + srvListMdnsScan.add(srvString); + } + } + } + } catch (e) { + log.severe('Error getting info from mdns-scan\n$e'); + } + return srvListMdnsScan; + } +} diff --git a/lib/src/mdns_scanner/list_of_srv_records.dart b/lib/src/mdns_scanner/list_of_srv_records.dart new file mode 100644 index 0000000..ecb302f --- /dev/null +++ b/lib/src/mdns_scanner/list_of_srv_records.dart @@ -0,0 +1,83 @@ +/// Service record list that is including the protocol, mostly _tcp, _udp may +/// not work +List tcpSrvRecordsList = [ + '_uscan._tcp', // Any HP-compatible network scanners + '_uscans._tcp', // Any SSL/TLS-capable HP-compatible network scanners + '_privet._tcp', // Any Google CloudPrint-capable printers or print services + '_http-alt._tcp', + '_scanner._tcp', // Are there any Bonjour-capable scanners + '_home-assistant._tcp', + '_pdl-datastream._tcp', // Any HP JetDirect-style network printers + '_ipp._tcp', // Are there any printers using the IPP protocol // "domain": "ipp" + '_ipps._tcp', // Any SSL/TLS capable IPP printers // "domain": "ipp" + '_http._tcp', // "domain": "bosch_shc", "name": "bosch shc*" + '_ldap._tcp', + '_gc._tcp', + '_kerberos._tcp', + '_kpasswd._tcp', + '_airplay._tcp', // Any Apple AirPlay-capable video displays here_ipps // [ { "domain": "apple_tv", "properties": { "model": "appletv*" } }, { "domain": "apple_tv", "properties": { "model": "audioaccessory*" } }, { "domain": "apple_tv", "properties": { "am": "airport*" } }, { "domain": "samsungtv", "properties": { "manufacturer": "samsung*" } } ] + '_raop._tcp', // Any Apple AirPlay-capable audio devices [ { "domain": "apple_tv", "properties": { "am": "appletv*" } }, { "domain": "apple_tv", "properties": { "am": "audioaccessory*" } }, { "domain": "apple_tv", "properties": { "am": "airport*" } } ], + '_ippusb._tcp', // Are there any shared printers that are using the IPP-over-USB protocol, i.e. USB-connected printers shared by a Mac + '_printer._tcp', // Any kinds of shared printers at all "domain": "brother", "name": "brother*" + '_ptp._tcp', // Any devices supporting the Picture Transfer Protocol over this network + '_googlecast._tcp', // Is there a ChromeCast-capable device in this network "domain": "cast" + '_airport._tcp', // Any Apple AirPort WiFi APs // "domain": "apple_tv" + '_esphomelib._tcp', // [ { "domain": "esphome" }, { "domain": "zha", "name": "tube*" } ] + '_mqtt._tcp', + '_autodiscover._tcp', + '_sip._tcp', + '_minecraft._tcp', + '_Volumio._tcp', // "domain": "volumio" + '_api._tcp', // [ { "domain": "baf", "properties": { "model": "haiku*" } }, { "domain": "baf", "properties": { "model": "i6*" } } ] + '_appletv-v2._tcp', // "domain": "apple_tv" + '_axis-video._tcp', // [ { "domain": "axis", "properties": { "macaddress": "00408c*" } }, { "domain": "axis", "properties": { "macaddress": "accc8e*" } }, { "domain": "axis", "properties": { "macaddress": "b8a44f*" } }, { "domain": "doorbird", "properties": { "macaddress": "1ccae3*"} } ], + '_bond._tcp', // "domain": "bond" + '_companion-link._tcp', // "domain": "apple_tv" + '_daap._tcp', // "domain": "forked_daapd" + '_dkapi._tcp', // "domain": "daikin" + '_dvl-deviceapi._tcp', // [ { "domain": "devolo_home_control" }, { "domain": "devolo_home_network", "properties": { "MT": "*" } } ] + '_easylink._tcp', // "domain": "modern_forms", "name": "wac*" + '_elg._tcp', // "domain": "elgato" + '_enphase-envoy._tcp', // "domain": "enphase_envoy" + '_fbx-api._tcp', // "domain": "freebox" + '_hap._tcp', // [ { "domain": "homekit_controller" }, { "domain": "zwave_me", "name": "*z.wave-me*" } ] + '_homekit._tcp', // "domain": "homekit" + '_hscp._tcp', // "domain": "apple_tv" + '_hue._tcp', // "domain": "hue" + '_hwenergy._tcp', // "domain": "homewizard", + '_kizbox._tcp', // "domain": "overkiz", "name": "gateway*" + '_leap._tcp', // "domain": "lutron_caseta" + '_lookin._tcp', // "domain": "lookin" + '_mediaremotetv._tcp', // "domain": "apple_tv" + '_nanoleafapi._tcp', // "domain": "nanoleaf" + '_nanoleafms._tcp', // "domain": "nanoleaf" + '_nut._tcp', // "domain": "nut" + '_octoprint._tcp', // "domain": "octoprint" + '_plexmediasvr._tcp', // "domain": "plex" + '_plugwise._tcp', // "domain": "plugwise" + '_powerview._tcp', // "domain": "hunterdouglas_powerview" + '_sideplay._tcp', // { "domain": "ecobee", "properties": { "mdl": "eb-*" } }, { "domain": "ecobee", "properties": { "mdl": "ecobee*" } } + '_sonos._tcp', // "domain": "sonos" + '_soundtouch._tcp', // "domain": "soundtouch" + '_spotify-connect', // "domain": "spotify" + '_ssh._tcp', // { "domain": "smappee", "name": "smappee1*" }, { "domain": "smappee", "name": "smappee2*" }, { "domain": "smappee", "name": "smappee50*" } + '_system-bridge._tcp', // "domain": "system_bridge" + '_touch-able._tcp', // "domain": "apple_tv" + '_viziocast._tcp', // "domain": "vizio" + '_wled._tcp', // "domain": "wled" + '_xbmc-jsonrpc-h._tcp', // "domain": "kodi" + '_zigate-zigbee-gateway._tcp', // "domain": "zha", "name": "*zigate*" + '_zwave-js-server._tcp', // "domain": "zwave_js" + '_axis-video._tcp', // "properties": { "macaddress": "00408c*" } "properties": { "macaddress": "accc8e*" } "properties": { "macaddress": "b8a44f*" } + '_androidtvremote2._tcp', + '_ewelink._tcp', + '_nvstream_dbd._tcp', +]; + +List udpSrvRecordsList = [ + '_api._udp', // "domain": "guardian" + '_hap._udp', // "domain": "homekit_controller" + '_miio._udp', // [ { "domain": "xiaomi_aqara" }, { "domain": "xiaomi_miio" }, { "domain": "yeelight", "name": "yeelink-*" } ] + '_sleep-proxy._udp', // "domain": "apple_tv" + '_system-bridge._udp', +]; diff --git a/lib/src/mdns_scanner/mdns_scanner.dart b/lib/src/mdns_scanner/mdns_scanner.dart new file mode 100644 index 0000000..1cdeafc --- /dev/null +++ b/lib/src/mdns_scanner/mdns_scanner.dart @@ -0,0 +1,120 @@ +import 'package:multicast_dns/multicast_dns.dart'; +import 'package:network_tools/network_tools.dart'; +import 'package:network_tools/src/mdns_scanner/get_srv_list_by_os/srv_list.dart'; +import 'package:network_tools/src/mdns_scanner/list_of_srv_records.dart'; +import 'package:network_tools/src/network_tools_utils.dart'; +import 'package:universal_io/io.dart'; + +class MdnsScanner { + /// This method searching for all the mdns devices in the network. + /// TODO: The implementation is **Lacking!** and will not find all the + /// TODO: results that actual exist in the network!, only some of them. + /// TODO: This is because missing functionality in dart + /// TODO: https://github.com/flutter/flutter/issues/97210 + /// TODO: In some cases we resolve this missing functionality using + /// TODO: specific os tools. + + static Future> searchMdnsDevices({ + bool forceUseOfSavedSrvRecordList = false, + }) async { + List srvRecordListToSearchIn; + + if (forceUseOfSavedSrvRecordList) { + srvRecordListToSearchIn = tcpSrvRecordsList; + srvRecordListToSearchIn.addAll(udpSrvRecordsList); + } else { + final List? srvRecordsFromOs = await SrvList.getSrvRecordList(); + + if (srvRecordsFromOs == null || srvRecordsFromOs.isEmpty) { + srvRecordListToSearchIn = tcpSrvRecordsList; + srvRecordListToSearchIn.addAll(udpSrvRecordsList); + } else { + srvRecordListToSearchIn = srvRecordsFromOs; + } + } + + final List>> activeHostListsFuture = []; + for (final String srvRecord in srvRecordListToSearchIn) { + activeHostListsFuture.add(findingMdnsWithAddress(srvRecord)); + } + + final List activeHostList = []; + + for (final Future> activeHostListFuture + in activeHostListsFuture) { + activeHostList.addAll(await activeHostListFuture); + } + + return activeHostList; + } + + static Future> findingMdnsWithAddress( + String serviceType, + ) async { + final List mdnsFoundList = []; + + final MDnsClient client = MDnsClient( + rawDatagramSocketFactory: ( + dynamic host, + int port, { + bool? reuseAddress, + bool? reusePort, + int? ttl, + }) { + return RawDatagramSocket.bind( + host, + port, + reusePort: !Platform.isWindows && !Platform.isAndroid, + ttl: ttl!, + ); + }, + ); + + await client.start(); + + await for (final PtrResourceRecord ptr in client.lookup( + ResourceRecordQuery.serverPointer(serviceType), + )) { + await for (final SrvResourceRecord srv + in client.lookup( + ResourceRecordQuery.service(ptr.domainName), + )) { + final MdnsInfo mdnsFound = MdnsInfo( + srvResourceRecord: srv, + ptrResourceRecord: ptr, + ); + mdnsFoundList.add(mdnsFound); + } + } + client.stop(); + + final List listOfActiveHost = []; + for (final MdnsInfo foundMdns in mdnsFoundList) { + final List? internetAddressList; + try { + internetAddressList = + await InternetAddress.lookup(foundMdns.mdnsSrvTarget); + + // There can be multiple devices with the same name + for (final InternetAddress internetAddress in internetAddressList) { + final ActiveHost tempHost = ActiveHost( + internetAddress: internetAddress, + mdnsInfoVar: foundMdns, + ); + listOfActiveHost.add(tempHost); + } + } catch (e) { + log.severe( + 'Error finding ip of mdns record ${foundMdns.ptrResourceRecord.name} srv target ${foundMdns.mdnsSrvTarget}, will add it with ip 0.0.0.0\n$e', + ); + final ActiveHost tempHost = ActiveHost( + internetAddress: InternetAddress('0.0.0.0'), + mdnsInfoVar: foundMdns, + ); + listOfActiveHost.add(tempHost); + } + } + + return listOfActiveHost; + } +} diff --git a/lib/src/models/active_host.dart b/lib/src/models/active_host.dart new file mode 100644 index 0000000..041153e --- /dev/null +++ b/lib/src/models/active_host.dart @@ -0,0 +1,209 @@ +import 'package:dart_ping/dart_ping.dart'; +import 'package:network_tools/src/models/mdns_info.dart'; +import 'package:network_tools/src/models/open_port.dart'; +import 'package:network_tools/src/models/sendable_active_host.dart'; +import 'package:network_tools/src/network_tools_utils.dart'; +import 'package:universal_io/io.dart'; + +/// ActiveHost which implements comparable +/// By default sort by hostId ascending +class ActiveHost extends Comparable { + ActiveHost({ + required this.internetAddress, + this.openPorts = const [], + PingData? pingData, + MdnsInfo? mdnsInfoVar, + }) { + final String tempAddress = internetAddress.address; + + if (tempAddress.contains('.')) { + hostId = tempAddress.substring( + tempAddress.lastIndexOf('.') + 1, + tempAddress.length, + ); + } else if (tempAddress.contains(':')) { + hostId = tempAddress.substring( + tempAddress.lastIndexOf(':') + 1, + tempAddress.length, + ); + } else { + hostId = '-1'; + } + + pingData ??= getPingData(tempAddress); + _pingData = pingData; + + hostName = setHostInfo(); + + // For some reason when internetAddress.host get called before the reverse + // there is weired value + weirdHostName = internetAddress.host; + + if (mdnsInfoVar != null) { + mdnsInfo = Future.value(mdnsInfoVar); + } else { + mdnsInfo = setMdnsInfo(); + } + + deviceName = setDeviceName(); + } + factory ActiveHost.buildWithAddress({ + required String address, + List openPorts = const [], + PingData? pingData, + MdnsInfo? mdnsInfo, + }) { + final InternetAddress? internetAddressTemp = + InternetAddress.tryParse(address); + if (internetAddressTemp == null) { + throw 'Cant parse address $address to InternetAddress'; + } + return ActiveHost( + internetAddress: internetAddressTemp, + openPorts: openPorts, + pingData: pingData, + mdnsInfoVar: mdnsInfo, + ); + } + + factory ActiveHost.fromSendableActiveHost({ + required SendableActiveHost sendableActiveHost, + List openPorts = const [], + MdnsInfo? mdnsInfo, + }) { + final InternetAddress? internetAddressTemp = + InternetAddress.tryParse(sendableActiveHost.address); + if (internetAddressTemp == null) { + throw 'Cant parse address ${sendableActiveHost.address} to InternetAddress'; + } + return ActiveHost( + internetAddress: internetAddressTemp, + openPorts: openPorts, + pingData: sendableActiveHost.pingData, + mdnsInfoVar: mdnsInfo, + ); + } + + Future resolveInfo() async { + await deviceName; + await mdnsInfo; + await hostName; + } + + static const generic = 'Generic Device'; + InternetAddress internetAddress; + + /// The device specific number in the ip address. In IPv4 numbers after the + /// last dot, in IPv6 the numbers after the last colon + late String hostId; + + /// Host name of the device, not to be confused with deviceName which does + /// not follow any internet protocol property + late Future hostName; + late String weirdHostName; + late final PingData _pingData; + + /// Mdns information of this device + late Future mdnsInfo; + + /// List of all the open port of this device + List openPorts; + + /// This device name does not following any guideline and is just some name + /// that we can show for the device. + /// Preferably hostName, if not than mDNS name, if not than will get the + /// value of [generic]. + /// This value **can change after the object got created** since getting + /// host name of device is running async function. + late Future deviceName; + PingData get pingData => _pingData; + Duration? get responseTime => _pingData.response?.time; + String get address => internetAddress.address; + + static PingData getPingData(String host) { + const int timeoutInSeconds = 1; + + PingData tempPingData = const PingData(); + + Ping(host, count: 1, timeout: timeoutInSeconds).stream.listen((pingData) { + final PingResponse? response = pingData.response; + if (response != null) { + final Duration? time = response.time; + if (time != null) { + tempPingData = pingData; + } + } + }); + return tempPingData; + } + + /// Try to find the host name of this device, if not exist host name will + /// stay null + Future setHostInfo() async { + // For some reason when internetAddress.host get called before the reverse + // there is weired value + weirdHostName = internetAddress.host; + + // In the future if mdnsInfo is null it will execute a search + // Currently the functionality is missing in dart multicast_dns package + // https://github.com/flutter/flutter/issues/96755 + + try { + internetAddress = await internetAddress.reverse(); + return internetAddress.host; + } catch (e) { + if (e is SocketException && + e.osError != null && + e.osError!.message == 'Name or service not known') { + // Some devices does not have host name and the reverse search will just + // throw exception. + // We don't need to print this crash as it is by design. + } else { + log.severe('Exception here: $e'); + } + } + return null; + } + + /// Try to find the mdns name of this device, if not exist mdns name will + /// be null + /// TODO: search mdns name for each device + Future setMdnsInfo() async { + return null; + } + + /// Set some kind of device name. + /// Will try couple of names, if all are null will just return [generic] + Future setDeviceName() async { + final String? hostNameTemp = await hostName; + + if (hostNameTemp != null) { + return hostNameTemp; + } + final MdnsInfo? mdnsTemp = await mdnsInfo; + if (mdnsTemp != null) { + return mdnsTemp.getOnlyTheStartOfMdnsName(); + } + return generic; + } + + @override + int get hashCode => address.hashCode; + + @override + bool operator ==(Object o) => o is ActiveHost && address == o.address; + + @override + int compareTo(ActiveHost other) { + return hostId.compareTo(other.hostId); + } + + @override + String toString() { + return 'Address: $address, HostId: $hostId, Time: ${responseTime?.inMilliseconds}ms, port: ${openPorts.join(",")}'; + } + + Future toStringFull() async { + return 'Address: $address, HostId: $hostId Time: ${responseTime?.inMilliseconds}ms, DeviceName: ${await deviceName}, HostName: ${await hostName}, MdnsInfo: ${await mdnsInfo}'; + } +} diff --git a/lib/src/models/callbacks.dart b/lib/src/models/callbacks.dart new file mode 100644 index 0000000..4fc77d3 --- /dev/null +++ b/lib/src/models/callbacks.dart @@ -0,0 +1,2 @@ +/// Progress Callback for scanners +typedef ProgressCallback = void Function(double progress); diff --git a/lib/src/models/mdns_info.dart b/lib/src/models/mdns_info.dart new file mode 100644 index 0000000..5ca5946 --- /dev/null +++ b/lib/src/models/mdns_info.dart @@ -0,0 +1,40 @@ +import 'package:multicast_dns/multicast_dns.dart'; + +class MdnsInfo { + MdnsInfo({ + required this.srvResourceRecord, + required this.ptrResourceRecord, + }); + + /// Also can be called target + String get mdnsName => srvResourceRecord.name; + + String get mdnsSrvTarget => srvResourceRecord.target; + int get mdnsPort => srvResourceRecord.port; + + /// Also can be called bundleId + String get mdnsDomainName => ptrResourceRecord.domainName; + + /// Srv record of the dns + String get mdnsServiceType { + final List ptrNameSplit = ptrResourceRecord.name.split('.'); + String tempString = ''; + if (ptrNameSplit.isNotEmpty) { + tempString = ptrNameSplit[0]; + } + if (ptrNameSplit.length >= 2) { + tempString = '$tempString.${ptrNameSplit[1]}'; + } + + return tempString; + } + + SrvResourceRecord srvResourceRecord; + + PtrResourceRecord ptrResourceRecord; + + /// mDNS name without the ._tcp.local + String getOnlyTheStartOfMdnsName() { + return mdnsName.substring(0, mdnsName.indexOf('.')); + } +} diff --git a/lib/src/models/open_port.dart b/lib/src/models/open_port.dart new file mode 100644 index 0000000..9532d25 --- /dev/null +++ b/lib/src/models/open_port.dart @@ -0,0 +1,28 @@ +/// Represents open port for a target Address +class OpenPort extends Comparable { + OpenPort(this._port, {this.isOpen = true}); + + final int _port; + final bool isOpen; + + int get port => _port; + + @override + int compareTo(OpenPort other) { + return _port.compareTo(other.port); + } + + @override + int get hashCode => _port.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is OpenPort && other.port == port; + } + + @override + String toString() { + return _port.toString(); + } +} diff --git a/lib/src/models/sendable_active_host.dart b/lib/src/models/sendable_active_host.dart new file mode 100644 index 0000000..2be38f5 --- /dev/null +++ b/lib/src/models/sendable_active_host.dart @@ -0,0 +1,7 @@ +import 'package:dart_ping/dart_ping.dart'; + +class SendableActiveHost{ + SendableActiveHost(this.address, this.pingData); + final String address; + final PingData pingData; +} diff --git a/lib/src/network_tools_utils.dart b/lib/src/network_tools_utils.dart new file mode 100644 index 0000000..bf3c8ad --- /dev/null +++ b/lib/src/network_tools_utils.dart @@ -0,0 +1,3 @@ +import 'package:logging/logging.dart'; + +final log = Logger("network_tools"); diff --git a/lib/src/port_scanner.dart b/lib/src/port_scanner.dart new file mode 100644 index 0000000..974aab3 --- /dev/null +++ b/lib/src/port_scanner.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:network_tools/src/models/active_host.dart'; +import 'package:network_tools/src/models/callbacks.dart'; +import 'package:network_tools/src/models/open_port.dart'; +import 'package:universal_io/io.dart'; + +/// Scans open port for a target Address or domain. +class PortScanner { + static const int defaultStartPort = 1; + static const int defaultEndPort = 1024; + static const List commonPorts = [ + 20, + 21, + 22, + 23, + 25, + 50, + 51, + 53, + 67, + 68, + 69, + 80, + 110, + 119, + 123, + 135, + 139, + 143, + 161, + 162, + 389, + 443, + 989, + 990, + 3389, + ]; + + /// Checks if the single [port] is open or not for the [target]. + static Future isOpen( + String target, + int port, { + Duration timeout = const Duration(milliseconds: 2000), + }) async { + if (port < 0 || port > 65535) { + throw 'Provide a valid port range between ' + '0 to 65535 or startPort < endPort is not true'; + } + final List address = + await InternetAddress.lookup(target, type: InternetAddressType.IPv4); + if (address.isNotEmpty) { + final String hostAddress = address[0].address; + return connectToPort( + activeHostsController: StreamController(), + address: hostAddress, + port: port, + timeout: timeout, + ); + } else { + throw 'Name can not be resolved'; + } + } + + /// Scans ports only listed in [portList] for a [target]. Progress can be + /// retrieved by [progressCallback] + /// Tries connecting ports before until [timeout] reached. + /// [resultsInAddressAscendingOrder] = false will return results faster but not in + /// ascending order and without [progressCallback]. + static Stream customDiscover( + String target, { + List portList = commonPorts, + ProgressCallback? progressCallback, + Duration timeout = const Duration(milliseconds: 2000), + bool resultsInAddressAscendingOrder = true, + }) async* { + final List address = + await InternetAddress.lookup(target, type: InternetAddressType.IPv4); + if (address.isNotEmpty) { + final String hostAddress = address[0].address; + final List> openPortList = []; + final StreamController activeHostsController = + StreamController(); + + for (int k = 0; k < portList.length; k++) { + if (portList[k] >= 0 && portList[k] <= 65535) { + openPortList.add( + connectToPort( + address: hostAddress, + port: portList[k], + timeout: timeout, + activeHostsController: activeHostsController, + ), + ); + } + } + + if (!resultsInAddressAscendingOrder) { + yield* activeHostsController.stream; + } + + int counter = 0; + + for (final Future openPortFuture in openPortList) { + final ActiveHost? openPort = await openPortFuture; + if (openPort == null) { + continue; + } + progressCallback?.call(counter * 100 / portList.length); + yield openPort; + counter++; + } + } else { + throw 'Name can not be resolved'; + } + } + + /// Scans port from [startPort] to [endPort] of [target]. Progress can be + /// retrieved by [progressCallback] + /// Tries connecting ports before until [timeout] reached. + static Stream scanPortsForSingleDevice( + String target, { + int startPort = defaultStartPort, + int endPort = defaultEndPort, + ProgressCallback? progressCallback, + Duration timeout = const Duration(milliseconds: 2000), + bool resultsInAddressAscendingOrder = true, + }) async* { + if (startPort < 0 || + endPort < 0 || + startPort > 65535 || + endPort > 65535 || + startPort > endPort) { + throw 'Provide a valid port range between 0 to 65535 or startPort <' + ' endPort is not true'; + } + + final List portList = []; + + for (int i = startPort; i <= endPort; ++i) { + portList.add(i); + } + + yield* customDiscover( + target, + portList: portList, + progressCallback: progressCallback, + timeout: timeout, + resultsInAddressAscendingOrder: resultsInAddressAscendingOrder, + ); + } + + static Future connectToPort({ + required String address, + required int port, + required Duration timeout, + required StreamController activeHostsController, + int recursionCount = 0, + }) async { + try { + final Socket s = await Socket.connect(address, port, timeout: timeout); + s.destroy(); + final ActiveHost activeHost = ActiveHost.buildWithAddress( + address: address, + openPorts: [OpenPort(port)], + ); + activeHostsController.add(activeHost); + + return activeHost; + } catch (e) { + if (e is! SocketException) { + rethrow; + } + + // Check if connection timed out or we got one of predefined errors + if (e.osError == null || _errorCodes.contains(e.osError?.errorCode)) { + return null; + } + + // Error 23,24: Too many open files in system + // e.osError can't be null here so `!` can be used + // Do no more than 2 retries to prevent infinite loops + if (recursionCount < 3 && + (e.osError!.errorCode == 23 || e.osError!.errorCode == 24)) { + // Hotfix: Wait for the timeout (+ a little more) to complete and retry + // -> Other connections must be closed now and the file handles available again + + await Future.delayed(timeout + const Duration(milliseconds: 250)); + + return connectToPort( + address: address, + port: port, + timeout: timeout, + activeHostsController: activeHostsController, + recursionCount: recursionCount + 1, + ); + } + + rethrow; + } + } + + static final _errorCodes = [13, 49, 61, 64, 65, 101, 111, 113]; +} diff --git a/network_tools_flutter/.gitignore b/network_tools_flutter/.gitignore deleted file mode 100644 index 96486fd..0000000 --- a/network_tools_flutter/.gitignore +++ /dev/null @@ -1,30 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock -**/doc/api/ -.dart_tool/ -.packages -build/ diff --git a/network_tools_flutter/CHANGELOG.md b/network_tools_flutter/CHANGELOG.md deleted file mode 100644 index fb43227..0000000 --- a/network_tools_flutter/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# Change Log - -## 0.0.1 - -* Initial support added for ping_ios_dart in network_tools diff --git a/network_tools_flutter/LICENSE b/network_tools_flutter/LICENSE deleted file mode 100644 index ba75c69..0000000 --- a/network_tools_flutter/LICENSE +++ /dev/null @@ -1 +0,0 @@ -TODO: Add your license here. diff --git a/network_tools_flutter/README.md b/network_tools_flutter/README.md deleted file mode 100644 index 1124707..0000000 --- a/network_tools_flutter/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Network Tools Flutter - -## Features - -This package will add support for flutter only features in network_tools, network_tools will still be required to be added in pubspec.yaml. - -## Getting started - -```dart -import 'package:network_tools_flutter/network_tools.dart'; - -``` - -## Usage - -```dart -main() { - NetworkToolsFlutter.init(); -} -``` - -## Additional information - -Currently getAllPingableDevicesAsync() is not working on ios because of plugin registration diff --git a/network_tools_flutter/analysis_options.yaml b/network_tools_flutter/analysis_options.yaml deleted file mode 100644 index a5744c1..0000000 --- a/network_tools_flutter/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/network_tools_flutter/lib/network_tools_flutter.dart b/network_tools_flutter/lib/network_tools_flutter.dart deleted file mode 100644 index 3bffe33..0000000 --- a/network_tools_flutter/lib/network_tools_flutter.dart +++ /dev/null @@ -1,3 +0,0 @@ -library network_tools_flutter; - -export 'src/host_scanner_flutter.dart'; diff --git a/network_tools_flutter/lib/src/host_scanner_flutter.dart b/network_tools_flutter/lib/src/host_scanner_flutter.dart deleted file mode 100644 index 7cb5685..0000000 --- a/network_tools_flutter/lib/src/host_scanner_flutter.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:async'; -import 'dart:isolate'; -import 'dart:math'; - -import 'package:dart_ping/dart_ping.dart'; -import 'package:dart_ping_ios/dart_ping_ios.dart'; -import 'package:flutter_isolate/flutter_isolate.dart'; -import 'package:network_tools/network_tools.dart'; -import 'package:universal_io/io.dart'; - -/// Scans for all hosts in a subnet. -class HostScannerFlutter { - /// Scans for all hosts in a particular subnet (e.g., 192.168.1.0/24) - /// Set maxHost to higher value if you are not getting results. - /// It won't firstHostId again unless previous scan is completed due to heavy - /// resource consumption. - /// [resultsInAddressAscendingOrder] = false will return results faster but not in - static Stream getAllPingableDevices( - String subnet, { - int firstHostId = HostScanner.defaultFirstHostId, - int lastHostId = HostScanner.defaultLastHostId, - int timeoutInSeconds = 1, - ProgressCallback? progressCallback, - bool resultsInAddressAscendingOrder = true, - }) async* { - const int scanRangeForIsolate = 51; - final int lastValidSubnet = HostScanner.validateAndGetLastValidSubnet( - subnet, firstHostId, lastHostId); - - for (int i = firstHostId; - i <= lastValidSubnet; - i += scanRangeForIsolate + 1) { - final limit = min(i + scanRangeForIsolate, lastValidSubnet); - final receivePort = ReceivePort(); - dynamic isolate; - - if(Platform.isAndroid || Platform.isIOS){ - // Flutter isolate is not implemented for other platforms than these two - isolate = await FlutterIsolate.spawn( - HostScannerFlutter._startSearchingDevices, receivePort.sendPort); - } else { - isolate = await Isolate.spawn( - HostScannerFlutter._startSearchingDevices, receivePort.sendPort); - } - - await for (final message in receivePort.asBroadcastStream()) { - if (message is SendPort) { - message.send([ - subnet, - i.toString(), - limit.toString(), - timeoutInSeconds.toString(), - resultsInAddressAscendingOrder.toString() - ]); - } else if (message is List) { - progressCallback - ?.call((i - firstHostId) * 100 / (lastValidSubnet - firstHostId)); - final activeHostFound = ActiveHost.fromSendableActiveHost( - sendableActiveHost: SendableActiveHost( - message[0], PingData.fromJson(message[1]))); - await activeHostFound.resolveInfo(); - yield activeHostFound; - } else if (message is String && message == 'Done') { - isolate.kill(); - } - } - } - } - - /// Will search devices in the network inside new isolate - @pragma('vm:entry-point') - static Future _startSearchingDevices(SendPort sendPort) async { - DartPingIOS.register(); - final port = ReceivePort(); - sendPort.send(port.sendPort); - - await for (List message in port) { - final String subnetIsolate = message[0]; - final int firstSubnetIsolate = int.parse(message[1]); - final int lastSubnetIsolate = int.parse(message[2]); - final int timeoutInSeconds = int.parse(message[3]); - final bool resultsInAddressAscendingOrder = message[4] == "true"; - - /// Will contain all the hosts that got discovered in the network, will - /// be use inorder to cancel on dispose of the page. - final Stream hostsDiscoveredInNetwork = - HostScanner.getAllSendablePingableDevices( - subnetIsolate, - firstHostId: firstSubnetIsolate, - lastHostId: lastSubnetIsolate, - timeoutInSeconds: timeoutInSeconds, - resultsInAddressAscendingOrder: resultsInAddressAscendingOrder, - ); - - await for (final SendableActiveHost activeHostFound - in hostsDiscoveredInNetwork) { - sendPort - .send([activeHostFound.address, activeHostFound.pingData.toJson()]); - } - sendPort.send('Done'); - } - } -} diff --git a/network_tools_flutter/pubspec.yaml b/network_tools_flutter/pubspec.yaml deleted file mode 100644 index 2753719..0000000 --- a/network_tools_flutter/pubspec.yaml +++ /dev/null @@ -1,63 +0,0 @@ -name: network_tools_flutter -description: Extended features of network_tools for flutter framework. -version: 0.0.1 -issue_tracker: https://github.com/osociety/network_tools/issues -repository: https://github.com/osociety/network_tools/tree/main/network_tools_flutter -publish_to: none - -environment: - sdk: ">=2.17.6 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - network_tools: - path: ../network_tools - # Multi-platform network ping utility. - dart_ping: ^8.0.1 - dart_ping_ios: ^3.0.0 - universal_io: ^2.2.0 - flutter_isolate: ^2.0.4 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/network_tools_flutter/test/network_tools_flutter_test.dart b/network_tools_flutter/test/network_tools_flutter_test.dart deleted file mode 100644 index 0da434d..0000000 --- a/network_tools_flutter/test/network_tools_flutter_test.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('adds one to input values', () {}); -} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..7a5993d --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,30 @@ +name: network_tools +description: Networking Tools library which can help you discover open ports, devices on subnet and many other things. +version: 3.2.2 +issue_tracker: https://github.com/osociety/network_tools/issues +repository: https://github.com/osociety/network_tools/tree/main/network_tools + +environment: + sdk: ">=2.17.6 <4.0.0" + +dependencies: + # Multi-platform network ping utility. + dart_ping: ^8.0.1 + # Deal with internationalized/localized messages and more. + intl: ^0.18.0 + # Easy to create multiple isolates for a function, keep it active and communicate with it. + isolate_manager: ^2.2.0+2 + # Debugging and error logging. + logging: ^1.1.1 + # Performing mDNS queries (e.g. Bonjour, Avahi). + multicast_dns: ^0.3.2+2 + # Process run helpers + process_run: ^0.12.5+2 + # Cross-platform 'dart:io' that works in all platforms. + universal_io: ^2.0.4 + +dev_dependencies: + # Set of lint rules for Dart. + lint: ^2.0.1 + # Writing and running Dart tests. + test: ^1.22.2 \ No newline at end of file diff --git a/network_tools_flutter/test/host_scan_flutter_test.dart b/test/host_scanner_test.dart similarity index 60% rename from network_tools_flutter/test/host_scan_flutter_test.dart rename to test/host_scanner_test.dart index 520b64a..ff9206f 100644 --- a/network_tools_flutter/test/host_scan_flutter_test.dart +++ b/test/host_scanner_test.dart @@ -1,10 +1,8 @@ import 'package:network_tools/network_tools.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:network_tools_flutter/network_tools_flutter.dart'; +import 'package:test/test.dart'; import 'package:universal_io/io.dart'; void main() { - TestWidgetsFlutterBinding.ensureInitialized(); int port = 0; String myOwnHost = "0.0.0.0"; String interfaceIp = myOwnHost.substring(0, myOwnHost.lastIndexOf('.')); @@ -32,10 +30,23 @@ void main() { }); group('Testing Host Scanner', () { - test('Running getAllPingableDevicesAsync tests', () async { + test('Running getAllPingableDevices tests', () { expectLater( //There should be at least one device pingable in network - HostScannerFlutter.getAllPingableDevices( + HostScanner.getAllPingableDevices(interfaceIp, timeoutInSeconds: 3), + emits(isA()), + ); + expectLater( + //Should emit at least our own local machine when pinging all hosts. + HostScanner.getAllPingableDevices(interfaceIp, timeoutInSeconds: 3), + emitsThrough(ActiveHost(internetAddress: InternetAddress(myOwnHost))), + ); + }); + + test('Running getAllPingableDevicesAsync tests', () { + expectLater( + //There should be at least one device pingable in network + HostScanner.getAllPingableDevicesAsync( interfaceIp, timeoutInSeconds: 3, ), @@ -43,13 +54,23 @@ void main() { ); expectLater( //Should emit at least our own local machine when pinging all hosts. - HostScannerFlutter.getAllPingableDevices( + HostScanner.getAllPingableDevicesAsync( interfaceIp, timeoutInSeconds: 3, ), emitsThrough(ActiveHost(internetAddress: InternetAddress(myOwnHost))), ); }); + + //todo: this test is not working on windows, not matter what. + test('Running scanDevicesForSinglePort tests', () { + expectLater( + HostScanner.scanDevicesForSinglePort( + interfaceIp, port, //ssh should be running at least in any host + ), // hence some host will be emitted + emits(isA()), + ); + }); }); tearDownAll(() { diff --git a/test/port_scanner_test.dart b/test/port_scanner_test.dart new file mode 100644 index 0000000..5a23979 --- /dev/null +++ b/test/port_scanner_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:network_tools/network_tools.dart'; +import 'package:test/test.dart'; +import 'package:universal_io/io.dart'; + +void main() { + int port = 0; // keep this value between 1-2034 + final List hostsWithOpenPort = []; + late ServerSocket server; + // Fetching interfaceIp and hostIp + setUpAll(() async { + //open a port in shared way because of hostscanner using same, + //if passed false then two hosts come up in search and breaks test. + server = + await ServerSocket.bind(InternetAddress.anyIPv4, port, shared: true); + port = server.port; + final interfaceList = + await NetworkInterface.list(); //will give interface list + if (interfaceList.isNotEmpty) { + final localInterface = + interfaceList.elementAt(0); //fetching first interface like en0/eth0 + if (localInterface.addresses.isNotEmpty) { + final address = localInterface.addresses + .elementAt(0) + .address; //gives IP address of GHA local machine. + final interfaceIp = address.substring(0, address.lastIndexOf('.')); + //ssh should be running at least in any host + await for (final host + in HostScanner.scanDevicesForSinglePort(interfaceIp, port)) { + hostsWithOpenPort.add(host); + } + } + } + }); + + group('Testing Port Scanner', () { + test('Running scanPortsForSingleDevice tests', () { + for (final activeHost in hostsWithOpenPort) { + final port = activeHost.openPorts.elementAt(0).port; + expectLater( + PortScanner.scanPortsForSingleDevice( + activeHost.address, + startPort: port - 1, + endPort: port, + ), + emitsThrough( + isA().having( + (p0) => p0.openPorts.contains(OpenPort(port)), + "Should match host having same open port", + equals(true), + ), + ), + ); + } + }); + + test('Running connectToPort tests', () { + for (final activeHost in hostsWithOpenPort) { + expectLater( + PortScanner.connectToPort( + address: activeHost.address, + port: port, + timeout: const Duration(seconds: 5), + activeHostsController: StreamController(), + ), + completion( + isA().having( + (p0) => p0.openPorts.contains(OpenPort(port)), + "Should match host having same open port", + equals(true), + ), + ), + ); + } + }); + test('Running customDiscover tests', () { + for (final activeHost in hostsWithOpenPort) { + expectLater( + PortScanner.customDiscover(activeHost.address, portList: [port]), + emits(isA()), + ); + } + }); + + test('Running customDiscover tests', () { + for (final activeHost in hostsWithOpenPort) { + expectLater( + PortScanner.isOpen(activeHost.address, port), + completion( + isA().having( + (p0) => p0.openPorts.contains(OpenPort(port)), + "Should match host having same open port", + equals(true), + ), + ), + ); + } + }); + }); + + tearDownAll(() { + server.close(); + }); +}