From bcfa8e6f630c7bdf495ee31de5f656bdf4a4faad Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Wed, 22 Nov 2023 09:27:31 +0330 Subject: [PATCH 1/9] add reusable validators --- lib/commands/devices/add_command.dart | 18 ++++-------------- lib/utils/common.dart | 10 ++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/commands/devices/add_command.dart b/lib/commands/devices/add_command.dart index 67cb08e..83ef4b6 100644 --- a/lib/commands/devices/add_command.dart +++ b/lib/commands/devices/add_command.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter_tools/src/base/io.dart'; -import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/custom_devices/custom_device_config.dart'; import 'package:interact/interact.dart'; @@ -21,8 +20,7 @@ import 'package:flutter_tools/src/base/common.dart'; class AddCommand extends BaseSnappCommand { AddCommand({ required super.flutterSdkManager, - required Platform platform, - }) : hostPlatform = HostRunnerPlatform.build(platform); + }) : hostPlatform = HostRunnerPlatform.build(flutterSdkManager.platform); /// create a HostPlatform instance based on the current platform /// with the help of this class we can make the commands platform specific @@ -149,7 +147,7 @@ class AddCommand extends BaseSnappCommand { final String targetStr = Input( prompt: 'Device IP-address:', validator: (s) { - if (_isValidIpAddr(s)) { + if (s.isValidIpAddress) { return true; } throw ValidationError('Invalid IP-address. Please try again.'); @@ -170,7 +168,6 @@ class AddCommand extends BaseSnappCommand { final String username = Input( prompt: 'Device Username:', - defaultValue: 'no username', ).interact(); // SSH expects IPv6 addresses to use the bracket syntax like URIs do too, @@ -230,7 +227,7 @@ class AddCommand extends BaseSnappCommand { remoteRunnerCommand = Input( prompt: 'Flutter path on device:', validator: (s) { - if (_isValidPath(s)) { + if (s.isValidPath) { return true; } throw ValidationError('Invalid Path to flutter. Please try again.'); @@ -350,13 +347,6 @@ class AddCommand extends BaseSnappCommand { return 0; } - // ignore: unused_element - bool _isValidHostname(String s) => hostnameRegex.hasMatch(s); - - bool _isValidPath(String s) => pathRegex.hasMatch(s); - - bool _isValidIpAddr(String s) => InternetAddress.tryParse(s) != null; - bool _isDuplicatedDeviceId(String s) { return customDevicesConfig.devices.any((element) => element.id == s); } @@ -399,7 +389,7 @@ class AddCommand extends BaseSnappCommand { ); } catch (e, s) { logger.printTrace( - 'Something went wrong while trying to find flutter. \n $e \n $s', + 'Something went wrong while trying to ping the device. \n $e \n $s', ); return false; diff --git a/lib/utils/common.dart b/lib/utils/common.dart index e4965c8..d460fcc 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -1,4 +1,14 @@ +import 'dart:io'; + final RegExp hostnameRegex = RegExp( r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'); final RegExp pathRegex = RegExp(r'^(.+)\/([^\/]+)$'); + +extension StringExt on String { + bool get isValidIpAddress => InternetAddress.tryParse(this) != null; + + bool get isValidHostname => hostnameRegex.hasMatch(this); + + bool get isValidPath => pathRegex.hasMatch(this); +} From 7f4d76db65c362347573453268238631a2fe2214 Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Wed, 22 Nov 2023 09:27:50 +0330 Subject: [PATCH 2/9] add ssh command --- lib/commands/devices/devices_command.dart | 21 +- lib/commands/devices/ssh_command.dart | 255 ++++++++++++++++++++++ lib/host_runner/host_runner_platform.dart | 88 +++++++- 3 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 lib/commands/devices/ssh_command.dart diff --git a/lib/commands/devices/devices_command.dart b/lib/commands/devices/devices_command.dart index 5ac19f0..10d4f1c 100644 --- a/lib/commands/devices/devices_command.dart +++ b/lib/commands/devices/devices_command.dart @@ -4,6 +4,7 @@ import 'package:snapp_cli/commands/base_command.dart'; import 'package:snapp_cli/commands/devices/add_command.dart'; import 'package:snapp_cli/commands/devices/delete_command.dart'; import 'package:snapp_cli/commands/devices/list_command.dart'; +import 'package:snapp_cli/commands/devices/ssh_command.dart'; import 'package:snapp_cli/commands/devices/update_ip_command.dart'; /// Add a new raspberry device to the Flutter SDK custom devices @@ -13,32 +14,26 @@ class DevicesCommand extends BaseSnappCommand { }) { // List command to list all custom devices addSubcommand( - ListCommand( - flutterSdkManager: flutterSdkManager, - ), + ListCommand(flutterSdkManager: flutterSdkManager), ); // Add command to add a new custom device addSubcommand( - AddCommand( - flutterSdkManager: flutterSdkManager, - platform: flutterSdkManager.platform, - ), + AddCommand(flutterSdkManager: flutterSdkManager), ); // Delete command to delete a custom device addSubcommand( - DeleteCommand( - flutterSdkManager: flutterSdkManager, - ), + DeleteCommand(flutterSdkManager: flutterSdkManager), ); // Update IP command to update the IP address of a custom device addSubcommand( - UpdateIpCommand( - flutterSdkManager: flutterSdkManager, - ), + UpdateIpCommand(flutterSdkManager: flutterSdkManager), ); + + addSubcommand(SshCommand(flutterSdkManager: flutterSdkManager)); + } @override diff --git a/lib/commands/devices/ssh_command.dart b/lib/commands/devices/ssh_command.dart new file mode 100644 index 0000000..8e51d3b --- /dev/null +++ b/lib/commands/devices/ssh_command.dart @@ -0,0 +1,255 @@ +// ignore_for_file: implementation_imports + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:interact/interact.dart'; +import 'package:snapp_cli/commands/base_command.dart'; +import 'package:flutter_tools/src/base/common.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:snapp_cli/host_runner/host_runner_platform.dart'; +import 'package:snapp_cli/utils/common.dart'; + +class SshCommand extends BaseSnappCommand { + SshCommand({ + required super.flutterSdkManager, + }) : hostPlatform = HostRunnerPlatform.build(flutterSdkManager.platform), + processRunner = ProcessUtils( + processManager: flutterSdkManager.processManager, + logger: flutterSdkManager.logger, + ); + + @override + String get description => 'Create an SSH connection to the remote device'; + + @override + String get name => 'ssh'; + + /// create a HostPlatform instance based on the current platform + /// with the help of this class we can make the commands platform specific + /// for example, the ping command is different on windows and linux + /// + /// only supports windows, linux and macos + final HostRunnerPlatform hostPlatform; + + final ProcessUtils processRunner; + + @override + FutureOr? run() async { + printSpaces(); + + logger.printStatus( + 'to create an SSH connection to the remote device, we need an IP address and a username', + ); + + final String deviceIp = Input( + prompt: 'Device IP-address:', + validator: (s) { + if (s.isValidIpAddress) { + return true; + } + throw ValidationError('Invalid IP-address. Please try again.'); + }, + ).interact(); + + final ip = InternetAddress(deviceIp); + + printSpaces(); + + final String username = Input( + prompt: 'Username:', + ).interact(); + + printSpaces(); + + final isDeviceReachable = await _tryPingDevice( + deviceIp, + ip.type == InternetAddressType.IPv6, + ); + + if (!isDeviceReachable) { + logger.printStatus( + 'Could not reach the device with the given IP-address.', + ); + + final continueWithoutPing = Confirm( + prompt: 'Do you want to continue anyway?', + defaultValue: true, // this is optional + waitForNewLine: true, // optional and will be false by default + ).interact(); + + if (!continueWithoutPing) { + printSpaces(); + logger.printStatus('Check your device IP-address and try again.'); + return 1; + } + } + + print('ip formatted: ${ip.address}'); + + printSpaces(); + + final sshConnectionCreated = + await _createPasswordlessSshConnection(username, ip); + + return sshConnectionCreated ? 0 : 1; + } + + Future _tryPingDevice(String pingTarget, bool ipv6) async { + final spinner = Spinner( + icon: '✔️', + leftPrompt: (done) => '', // prompts are optional + rightPrompt: (done) => done + ? 'pinging device completed.' + : 'pinging device to check if it is reachable.', + ).interact(); + + await Future.delayed(Duration(seconds: 2)); + final RunResult result; + try { + result = await processRunner.run( + hostPlatform.pingCommand(ipv6: ipv6, pingTarget: pingTarget), + timeout: Duration(seconds: 10), + ); + } catch (e, s) { + logger.printTrace( + 'Something went wrong while trying to find flutter. \n $e \n $s', + ); + + return false; + } finally { + spinner.done(); + + printSpaces(); + } + + logger.printTrace('Ping Command ExitCode: ${result.exitCode}'); + logger.printTrace('Ping Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('Ping Command Stderr: ${result.stderr}'); + + printSpaces(); + + if (result.exitCode != 0) { + return false; + } + + // If the user doesn't configure a ping success regex, any ping with exitCode zero + // is good enough. Otherwise we check if either stdout or stderr have a match of + // the pingSuccessRegex. + final RegExp? pingSuccessRegex = hostPlatform.pingSuccessRegex; + + return pingSuccessRegex == null || + pingSuccessRegex.hasMatch(result.stdout) || + pingSuccessRegex.hasMatch(result.stderr); + } + + Future _createPasswordlessSshConnection( + String username, + InternetAddress ip, + ) async { + // SSH expects IPv6 addresses to use the bracket syntax like URIs do too, + // but the IPv6 the user enters is a raw IPv6 address, so we need to wrap it. + final String sshTarget = (username.isNotEmpty ? '$username@' : '') + + (ip.type == InternetAddressType.IPv6 ? '[${ip.address}]' : ip.address); + + final spinner = Spinner( + icon: '✔️', + leftPrompt: (done) => '', // prompts are optional + rightPrompt: (done) => done + ? 'creating SSH connection completed.' + : 'creating SSH connection.', + ).interact(); + + // create a directory in the user's home directory + final snappCliDirectory = await _createSnapppCliDirectory(); + + final sshKeyFile = + await _generateSshKeyFile(processRunner, snappCliDirectory); + + spinner.done(); + + final sshKeyCopied = await _copySshKeyToRemote( + sshKeyFile, + ip.type == InternetAddressType.IPv6, + sshTarget, + ); + + printSpaces(); + + return sshKeyCopied; + } + + /// Creates a directory in the user's home directory + /// to store the snapp_cli related files like ssh keys + Future _createSnapppCliDirectory() async { + final String homePath = hostPlatform.homePath; + final String snapppCliDirectoryPath = '$homePath/.snapp_cli'; + + final snappCliDirectory = Directory(snapppCliDirectoryPath); + + if (!(await snappCliDirectory.exists())) { + await snappCliDirectory.create(); + } + + return snappCliDirectory; + } + + Future _generateSshKeyFile( + ProcessUtils processRunner, + Directory snappCliDir, + ) async { + // generate random 6 digit file name + final fileName = Random().nextInt(900000) + 100000; + + final keyFile = File('${snappCliDir.path}/$fileName'); + final RunResult result; + try { + result = await processRunner.run( + hostPlatform.generateSshKeyCommand( + filePath: keyFile.path, + ), + timeout: Duration(seconds: 10), + ); + } catch (e, s) { + throwToolExit( + 'Something went wrong while generating the ssh key. \nException: $s \nStack: $s'); + } + + logger.printTrace('SSH Command ExitCode: ${result.exitCode}'); + logger.printTrace('SSH Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('SSH Command Stderr: ${result.stderr}'); + + if (result.exitCode != 0) { + throwToolExit('Something went wrong while generating the ssh key.'); + } + + return keyFile; + } + + Future _copySshKeyToRemote( + File sshKeyFile, + bool ipv6, + String targetDevice, + ) async { + final RunResult result; + try { + result = await processRunner.run( + hostPlatform.copySshKeyCommand( + filePath: sshKeyFile.path, + ipv6: ipv6, + targetDevice: targetDevice, + ), + ); + } catch (e, s) { + throwToolExit( + 'Something went wrong while generating the ssh key. \n $s \n $s'); + } + + logger.printTrace('SSH key copy Command ExitCode: ${result.exitCode}'); + logger.printTrace('SSH key copy Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('SSH key copy Command Stderr: ${result.stderr}'); + + return result.exitCode == 0; + } +} diff --git a/lib/host_runner/host_runner_platform.dart b/lib/host_runner/host_runner_platform.dart index ae3a170..4299f8c 100644 --- a/lib/host_runner/host_runner_platform.dart +++ b/lib/host_runner/host_runner_platform.dart @@ -25,6 +25,8 @@ abstract class HostRunnerPlatform { String get currentSourcePath; + String get homePath; + List commandRunner(List commands); List scpCommand({ @@ -32,6 +34,7 @@ abstract class HostRunnerPlatform { required String source, required String dest, bool lastCommand = false, + String endCharacter = ';', }) => [ 'scp', @@ -40,7 +43,7 @@ abstract class HostRunnerPlatform { 'BatchMode=yes', if (ipv6) '-6', source, - '$dest ${lastCommand ? '' : ';'}', + '$dest ${lastCommand ? '' : endCharacter}', ]; List sshCommand({ @@ -48,6 +51,7 @@ abstract class HostRunnerPlatform { required String sshTarget, required String command, bool lastCommand = false, + String endCharacter = ';', }) => [ 'ssh', @@ -55,13 +59,14 @@ abstract class HostRunnerPlatform { 'BatchMode=yes', if (ipv6) '-6', sshTarget, - '$command ${lastCommand ? '' : ';'}', + '$command ${lastCommand ? '' : endCharacter}', ]; List sshMultiCommand({ required bool ipv6, required String sshTarget, required List commands, + String endCharacter = ';', }) => [ 'ssh', @@ -69,7 +74,8 @@ abstract class HostRunnerPlatform { 'BatchMode=yes', if (ipv6) '-6', sshTarget, - ...commands.map((e) => e.trim().endsWith(' ;') ? e : '$e;'), + ...commands.map( + (e) => e.trim().endsWith(' $endCharacter') ? e : '$e$endCharacter'), ]; List pingCommand({ @@ -78,6 +84,14 @@ abstract class HostRunnerPlatform { }); RegExp? get pingSuccessRegex => null; + + List generateSshKeyCommand({required String filePath}); + + List copySshKeyCommand({ + required String filePath, + required bool ipv6, + required String targetDevice, + }); } class WindowsHostRunnerPlatform extends HostRunnerPlatform { @@ -89,6 +103,9 @@ class WindowsHostRunnerPlatform extends HostRunnerPlatform { @override String get currentSourcePath => '.\\'; + @override + String get homePath => platform.environment['UserProfile']!; + @override List commandRunner(List commands) { return [ @@ -111,6 +128,35 @@ class WindowsHostRunnerPlatform extends HostRunnerPlatform { @override RegExp? get pingSuccessRegex => RegExp(r'[<=]\d+ms'); + + @override + List generateSshKeyCommand({ + required String filePath, + }) => + [ + 'ssh-keygen', + '-t', + 'rsa', + '-b', + '2048', + '-f', + filePath, + '-q', + '-N', + '\'""\'', + ]; + + @override + List copySshKeyCommand({ + required String filePath, + required bool ipv6, + required String targetDevice, + }) { + return commandRunner([ + 'type $filePath |', + 'ssh $targetDevice "cat >> .ssh/authorized_keys"' + ]); + } } class UnixHostRunnerPlatform extends HostRunnerPlatform { @@ -122,6 +168,9 @@ class UnixHostRunnerPlatform extends HostRunnerPlatform { @override String get currentSourcePath => './'; + @override + String get homePath => platform.environment['HOME']!; + @override List commandRunner(List commands) { return [ @@ -141,6 +190,39 @@ class UnixHostRunnerPlatform extends HostRunnerPlatform { '400', pingTarget, ]; + + @override + List generateSshKeyCommand({ + required String filePath, + }) => + [ + 'ssh-keygen', + '-t', + 'rsa', + '-b', + '2048', + '-f', + filePath, + '-q', + '-N', + '""', + ]; + + @override + List copySshKeyCommand({ + required String filePath, + required bool ipv6, + required String targetDevice, + }) { + return [ + 'ssh-copy-id', + if (ipv6) '-6', + '-f', + '-i', + filePath, + targetDevice, + ]; + } } extension StringListExtension on List { From b08e3c9cb51cb6f99fddf8c7bb43c50e46b6ac8a Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Thu, 23 Nov 2023 16:22:57 +0330 Subject: [PATCH 3/9] add ssh-add command to ssh --- lib/commands/devices/ssh_command.dart | 95 ++++++++++++++++------- lib/host_runner/host_runner_platform.dart | 7 +- pubspec.yaml | 1 + 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/lib/commands/devices/ssh_command.dart b/lib/commands/devices/ssh_command.dart index 8e51d3b..93159e1 100644 --- a/lib/commands/devices/ssh_command.dart +++ b/lib/commands/devices/ssh_command.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'package:dartssh2/dartssh2.dart'; import 'package:interact/interact.dart'; import 'package:snapp_cli/commands/base_command.dart'; import 'package:flutter_tools/src/base/common.dart'; @@ -148,11 +149,6 @@ class SshCommand extends BaseSnappCommand { String username, InternetAddress ip, ) async { - // SSH expects IPv6 addresses to use the bracket syntax like URIs do too, - // but the IPv6 the user enters is a raw IPv6 address, so we need to wrap it. - final String sshTarget = (username.isNotEmpty ? '$username@' : '') + - (ip.type == InternetAddressType.IPv6 ? '[${ip.address}]' : ip.address); - final spinner = Spinner( icon: '✔️', leftPrompt: (done) => '', // prompts are optional @@ -162,17 +158,18 @@ class SshCommand extends BaseSnappCommand { ).interact(); // create a directory in the user's home directory - final snappCliDirectory = await _createSnapppCliDirectory(); + final snappCliDirectory = await _createSnappCliDirectory(); + + final sshKeys = await _generateSshKeyFile(processRunner, snappCliDirectory); - final sshKeyFile = - await _generateSshKeyFile(processRunner, snappCliDirectory); + await _addSshKeyToAgent(sshKeys.privateKey); spinner.done(); final sshKeyCopied = await _copySshKeyToRemote( - sshKeyFile, - ip.type == InternetAddressType.IPv6, - sshTarget, + sshKeys.publicKey, + username, + ip, ); printSpaces(); @@ -182,7 +179,7 @@ class SshCommand extends BaseSnappCommand { /// Creates a directory in the user's home directory /// to store the snapp_cli related files like ssh keys - Future _createSnapppCliDirectory() async { + Future _createSnappCliDirectory() async { final String homePath = hostPlatform.homePath; final String snapppCliDirectoryPath = '$homePath/.snapp_cli'; @@ -195,14 +192,16 @@ class SshCommand extends BaseSnappCommand { return snappCliDirectory; } - Future _generateSshKeyFile( + /// Generates a ssh key file in the snapp_cli directory + Future<({File privateKey, File publicKey})> _generateSshKeyFile( ProcessUtils processRunner, Directory snappCliDir, ) async { // generate random 6 digit file name - final fileName = Random().nextInt(900000) + 100000; + final randomNumber = Random().nextInt(900000) + 100000; + + final keyFile = File('${snappCliDir.path}/id_rsa_$randomNumber'); - final keyFile = File('${snappCliDir.path}/$fileName'); final RunResult result; try { result = await processRunner.run( @@ -224,32 +223,68 @@ class SshCommand extends BaseSnappCommand { throwToolExit('Something went wrong while generating the ssh key.'); } - return keyFile; + return (privateKey: keyFile, publicKey: File('${keyFile.path}.pub')); } - Future _copySshKeyToRemote( - File sshKeyFile, - bool ipv6, - String targetDevice, - ) async { + /// Adds the ssh key to the ssh-agent + Future _addSshKeyToAgent(File sshKey) async { final RunResult result; try { result = await processRunner.run( - hostPlatform.copySshKeyCommand( - filePath: sshKeyFile.path, - ipv6: ipv6, - targetDevice: targetDevice, + hostPlatform.commandRunner( + [ + 'ssh-add', + sshKey.path, + ], ), + timeout: Duration(seconds: 10), ); } catch (e, s) { throwToolExit( - 'Something went wrong while generating the ssh key. \n $s \n $s'); + 'Something went wrong while adding the key to ssh-agent. \nException: $s \nStack: $s'); + } + + logger.printTrace('ssh-add Command ExitCode: ${result.exitCode}'); + logger.printTrace('ssh-add Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('ssh-add Command Stderr: ${result.stderr}'); + + if (result.exitCode != 0) { + throwToolExit('Something went wrong while adding the key to ssh-agent.'); } + } + + Future _copySshKeyToRemote( + File sshKeyFile, + String username, + InternetAddress ip, + ) async { + final client = SSHClient( + await SSHSocket.connect( + ip.address, + 22, + timeout: Duration(seconds: 10), + ), + username: username, + onPasswordRequest: () { + stdout.write('Password: '); + stdin.echoMode = false; + return stdin.readLineSync() ?? exit(1); + }, + ); + + final session = await client.execute('cat >> .ssh/authorized_keys'); + await session.stdin.addStream(sshKeyFile.openRead().cast()); + + // Close the sink to send EOF to the remote process. + await session.stdin.close(); + + // Wait for session to exit to ensure all data is flushed to the remote process. + await session.done; - logger.printTrace('SSH key copy Command ExitCode: ${result.exitCode}'); - logger.printTrace('SSH key copy Command Stdout: ${result.stdout.trim()}'); - logger.printTrace('SSH key copy Command Stderr: ${result.stderr}'); + // You can get the exit code after the session is done + print(session.exitCode); - return result.exitCode == 0; + client.close(); + return true; } } diff --git a/lib/host_runner/host_runner_platform.dart b/lib/host_runner/host_runner_platform.dart index 4299f8c..8c15014 100644 --- a/lib/host_runner/host_runner_platform.dart +++ b/lib/host_runner/host_runner_platform.dart @@ -87,6 +87,11 @@ abstract class HostRunnerPlatform { List generateSshKeyCommand({required String filePath}); + List addSshKeyToAgent({required String filePath}) => [ + 'ssh-add', + filePath, + ]; + List copySshKeyCommand({ required String filePath, required bool ipv6, @@ -143,7 +148,7 @@ class WindowsHostRunnerPlatform extends HostRunnerPlatform { filePath, '-q', '-N', - '\'""\'', + '', ]; @override diff --git a/pubspec.yaml b/pubspec.yaml index 17b855e..1b2bde6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: collection: ^1.18.0 interact: ^2.2.0 process: ^4.2.4 + dartssh2: ^2.8.2 dev_dependencies: lints: ^2.0.0 From c5e7f3cec754cafc30afa1a350b8e7f6936c28b0 Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Mon, 27 Nov 2023 12:35:26 +0330 Subject: [PATCH 4/9] fix the linux issue --- lib/host_runner/host_runner_platform.dart | 47 ++++++----------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/lib/host_runner/host_runner_platform.dart b/lib/host_runner/host_runner_platform.dart index 8c15014..bf509a8 100644 --- a/lib/host_runner/host_runner_platform.dart +++ b/lib/host_runner/host_runner_platform.dart @@ -85,7 +85,18 @@ abstract class HostRunnerPlatform { RegExp? get pingSuccessRegex => null; - List generateSshKeyCommand({required String filePath}); + List generateSshKeyCommand({required String filePath}) => [ + 'ssh-keygen', + '-t', + 'rsa', + '-b', + '2048', + '-f', + filePath, + '-q', + '-N', + '', + ]; List addSshKeyToAgent({required String filePath}) => [ 'ssh-add', @@ -134,23 +145,6 @@ class WindowsHostRunnerPlatform extends HostRunnerPlatform { @override RegExp? get pingSuccessRegex => RegExp(r'[<=]\d+ms'); - @override - List generateSshKeyCommand({ - required String filePath, - }) => - [ - 'ssh-keygen', - '-t', - 'rsa', - '-b', - '2048', - '-f', - filePath, - '-q', - '-N', - '', - ]; - @override List copySshKeyCommand({ required String filePath, @@ -196,23 +190,6 @@ class UnixHostRunnerPlatform extends HostRunnerPlatform { pingTarget, ]; - @override - List generateSshKeyCommand({ - required String filePath, - }) => - [ - 'ssh-keygen', - '-t', - 'rsa', - '-b', - '2048', - '-f', - filePath, - '-q', - '-N', - '""', - ]; - @override List copySshKeyCommand({ required String filePath, From 98e13946dbc3f7f7bad2123b6f08aa537a132ea0 Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Mon, 27 Nov 2023 12:41:59 +0330 Subject: [PATCH 5/9] add log extension method --- lib/command_runner/command_runner.dart | 15 +++++--------- lib/commands/base_command.dart | 6 ------ lib/commands/devices/add_command.dart | 28 +++++++++++++------------- lib/commands/devices/list_command.dart | 3 ++- lib/commands/devices/ssh_command.dart | 16 +++++++-------- lib/utils/common.dart | 9 +++++++++ 6 files changed, 38 insertions(+), 39 deletions(-) diff --git a/lib/command_runner/command_runner.dart b/lib/command_runner/command_runner.dart index 5d0d07c..c4ad0fb 100644 --- a/lib/command_runner/command_runner.dart +++ b/lib/command_runner/command_runner.dart @@ -2,6 +2,7 @@ import 'package:args/command_runner.dart'; import 'package:interact/interact.dart'; import 'package:snapp_cli/commands/devices/devices_command.dart'; +import 'package:snapp_cli/utils/common.dart'; import 'package:snapp_cli/utils/flutter_sdk.dart'; import 'package:flutter_tools/src/runner/flutter_command_runner.dart'; import 'package:flutter_tools/src/base/common.dart'; @@ -41,7 +42,7 @@ class SnappCliCommandRunner extends CommandRunner { final isLinuxEnabled = flutterSdkManager.isLinuxEnabled; if (!areCustomDevicesEnabled || !isLinuxEnabled) { - printSpaces(); + logger.printSpaces(); logger.printStatus( ''' @@ -50,7 +51,7 @@ This is a one time setup and will not be required again. ''', ); - printSpaces(); + logger.printSpaces(); final enableConfigs = Confirm( prompt: 'Do you want to enable them now?', @@ -58,7 +59,7 @@ This is a one time setup and will not be required again. waitForNewLine: true, // optional and will be false by default ).interact(); - printSpaces(); + logger.printSpaces(); if (!enableConfigs) { throwToolExit(''' @@ -111,7 +112,7 @@ flutter config --enable-custom-devices --enable-linux-desktop } finally { spinner.done(); - printSpaces(); + logger.printSpaces(); } if (result.exitCode != 0) { @@ -124,10 +125,4 @@ flutter config --enable-custom-devices --enable-linux-desktop '''); } } - - void printSpaces([int n = 2]) { - for (int i = 0; i < n; i++) { - logger.printStatus(' '); - } - } } diff --git a/lib/commands/base_command.dart b/lib/commands/base_command.dart index 02ee9d8..6e30e24 100644 --- a/lib/commands/base_command.dart +++ b/lib/commands/base_command.dart @@ -16,12 +16,6 @@ abstract class BaseSnappCommand extends Command { CustomDevicesConfig get customDevicesConfig => flutterSdkManager.customDeviceConfig; Logger get logger => flutterSdkManager.logger; - - void printSpaces([int n = 2]) { - for (int i = 0; i < n; i++) { - logger.printStatus(' '); - } - } } extension ArgResultsExtension on ArgResults { diff --git a/lib/commands/devices/add_command.dart b/lib/commands/devices/add_command.dart index 83ef4b6..fc9a1bc 100644 --- a/lib/commands/devices/add_command.dart +++ b/lib/commands/devices/add_command.dart @@ -37,7 +37,7 @@ class AddCommand extends BaseSnappCommand { @override Future run() async { - printSpaces(); + logger.printSpaces(); final addCommandOptions = [ 'Express (recommended)', @@ -57,7 +57,7 @@ class AddCommand extends BaseSnappCommand { } Future _addPredefinedDevice() async { - printSpaces(); + logger.printSpaces(); final selectedPredefinedDevice = Select( prompt: 'Select your device', @@ -97,7 +97,7 @@ class AddCommand extends BaseSnappCommand { /// path to the icu data file on the remote machine const hostIcuDataClone = '$hostBuildClonePath/engine'; - printSpaces(); + logger.printSpaces(); String id = predefinedConfig?.id ?? ''; @@ -118,7 +118,7 @@ class AddCommand extends BaseSnappCommand { }, ).interact().trim(); - printSpaces(); + logger.printSpaces(); } String label = predefinedConfig?.label ?? ''; @@ -137,7 +137,7 @@ class AddCommand extends BaseSnappCommand { }, ).interact(); - printSpaces(); + logger.printSpaces(); } logger.printStatus( @@ -160,7 +160,7 @@ class AddCommand extends BaseSnappCommand { final InternetAddress loopbackIp = ipv6 ? InternetAddress.loopbackIPv6 : InternetAddress.loopbackIPv4; - printSpaces(); + logger.printSpaces(); logger.printStatus( 'Please enter the username used for ssh-ing into the remote device. (example: pi)', @@ -178,7 +178,7 @@ class AddCommand extends BaseSnappCommand { final String formattedLoopbackIp = ipv6 ? '[${loopbackIp.address}]' : loopbackIp.address; - printSpaces(); + logger.printSpaces(); final isDeviceReachable = await _tryPingDevice(targetStr, ipv6); @@ -194,13 +194,13 @@ class AddCommand extends BaseSnappCommand { ).interact(); if (!continueWithoutPing) { - printSpaces(); + logger.printSpaces(); logger.printStatus('Check your device IP-address and try again.'); return 1; } } - printSpaces(); + logger.printSpaces(); logger.printStatus( 'We need the exact path of your flutter command line tools on the remote device. ' @@ -336,13 +336,13 @@ class AddCommand extends BaseSnappCommand { customDevicesConfig.add(config); - printSpaces(); + logger.printSpaces(); logger.printStatus( '✔️ Successfully added custom device to config file at "${customDevicesConfig.configPath}". ✔️', ); - printSpaces(); + logger.printSpaces(); return 0; } @@ -396,14 +396,14 @@ class AddCommand extends BaseSnappCommand { } finally { spinner.done(); - printSpaces(); + logger.printSpaces(); } logger.printTrace('Ping Command ExitCode: ${result.exitCode}'); logger.printTrace('Ping Command Stdout: ${result.stdout.trim()}'); logger.printTrace('Ping Command Stderr: ${result.stderr}'); - printSpaces(); + logger.printSpaces(); if (result.exitCode != 0) { return false; @@ -456,7 +456,7 @@ class AddCommand extends BaseSnappCommand { } finally { spinner.done(); - printSpaces(); + logger.printSpaces(); } logger.printTrace('Find Flutter ExitCode: ${result.exitCode}'); diff --git a/lib/commands/devices/list_command.dart b/lib/commands/devices/list_command.dart index f567e3f..b044ab8 100644 --- a/lib/commands/devices/list_command.dart +++ b/lib/commands/devices/list_command.dart @@ -2,6 +2,7 @@ import 'package:snapp_cli/commands/base_command.dart'; import 'package:flutter_tools/src/base/process.dart'; +import 'package:snapp_cli/utils/common.dart'; /// List all custom devices added to the Flutter SDK with custom-devices command /// it will utilize the `flutter custom-devices list` command to show the list @@ -30,7 +31,7 @@ class ListCommand extends BaseSnappCommand { timeout: Duration(seconds: 10), ); - printSpaces(); + logger.printSpaces(); logger.printStatus(result.stdout); diff --git a/lib/commands/devices/ssh_command.dart b/lib/commands/devices/ssh_command.dart index 93159e1..60295c5 100644 --- a/lib/commands/devices/ssh_command.dart +++ b/lib/commands/devices/ssh_command.dart @@ -38,7 +38,7 @@ class SshCommand extends BaseSnappCommand { @override FutureOr? run() async { - printSpaces(); + logger.printSpaces(); logger.printStatus( 'to create an SSH connection to the remote device, we need an IP address and a username', @@ -56,13 +56,13 @@ class SshCommand extends BaseSnappCommand { final ip = InternetAddress(deviceIp); - printSpaces(); + logger.printSpaces(); final String username = Input( prompt: 'Username:', ).interact(); - printSpaces(); + logger.printSpaces(); final isDeviceReachable = await _tryPingDevice( deviceIp, @@ -81,7 +81,7 @@ class SshCommand extends BaseSnappCommand { ).interact(); if (!continueWithoutPing) { - printSpaces(); + logger.printSpaces(); logger.printStatus('Check your device IP-address and try again.'); return 1; } @@ -89,7 +89,7 @@ class SshCommand extends BaseSnappCommand { print('ip formatted: ${ip.address}'); - printSpaces(); + logger.printSpaces(); final sshConnectionCreated = await _createPasswordlessSshConnection(username, ip); @@ -122,14 +122,14 @@ class SshCommand extends BaseSnappCommand { } finally { spinner.done(); - printSpaces(); + logger.printSpaces(); } logger.printTrace('Ping Command ExitCode: ${result.exitCode}'); logger.printTrace('Ping Command Stdout: ${result.stdout.trim()}'); logger.printTrace('Ping Command Stderr: ${result.stderr}'); - printSpaces(); + logger.printSpaces(); if (result.exitCode != 0) { return false; @@ -172,7 +172,7 @@ class SshCommand extends BaseSnappCommand { ip, ); - printSpaces(); + logger.printSpaces(); return sshKeyCopied; } diff --git a/lib/utils/common.dart b/lib/utils/common.dart index d460fcc..9130184 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:flutter_tools/src/base/logger.dart'; final RegExp hostnameRegex = RegExp( r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'); @@ -12,3 +13,11 @@ extension StringExt on String { bool get isValidPath => pathRegex.hasMatch(this); } + +extension LoggerExt on Logger { + void printSpaces([int count = 1]) { + for (var i = 0; i < count; i++) { + print(''); + } + } +} From 45b610c987a0a9c3fa646307a0927a10375d536f Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Mon, 27 Nov 2023 13:24:31 +0330 Subject: [PATCH 6/9] ssh service class created --- lib/service/ssh_service.dart | 305 +++++++++++++++++++++++++++++++++++ lib/utils/common.dart | 2 + 2 files changed, 307 insertions(+) create mode 100644 lib/service/ssh_service.dart diff --git a/lib/service/ssh_service.dart b/lib/service/ssh_service.dart new file mode 100644 index 0000000..f76127f --- /dev/null +++ b/lib/service/ssh_service.dart @@ -0,0 +1,305 @@ +// ignore_for_file: implementation_imports + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:interact/interact.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/common.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:snapp_cli/host_runner/host_runner_platform.dart'; +import 'package:snapp_cli/snapp_cli.dart'; +import 'package:snapp_cli/utils/common.dart'; + +class SshService { + SshService({ + required FlutterSdkManager flutterSdkManager, + }) : logger = flutterSdkManager.logger, + hostPlatform = HostRunnerPlatform.build(flutterSdkManager.platform), + processRunner = ProcessUtils( + processManager: flutterSdkManager.processManager, + logger: flutterSdkManager.logger, + ); + + final Logger logger; + + final HostRunnerPlatform hostPlatform; + + final ProcessUtils processRunner; + + Future tryPingDevice(String pingTarget, bool ipv6) async { + final spinner = Spinner( + icon: '✔️', + leftPrompt: (done) => '', // prompts are optional + rightPrompt: (done) => done + ? 'pinging device completed.' + : 'pinging device to check if it is reachable.', + ).interact(); + + await Future.delayed(Duration(seconds: 2)); + final RunResult result; + try { + result = await processRunner.run( + hostPlatform.pingCommand(ipv6: ipv6, pingTarget: pingTarget), + timeout: Duration(seconds: 10), + ); + } catch (e, s) { + logger.printTrace( + 'Something went wrong while pinging the device. \nException: $s \nStack: $s', + ); + + return false; + } finally { + spinner.done(); + + logger.printSpaces(); + } + + logger.printTrace('Ping Command ExitCode: ${result.exitCode}'); + logger.printTrace('Ping Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('Ping Command Stderr: ${result.stderr}'); + + logger.printSpaces(); + + if (result.exitCode != 0) { + return false; + } + + // If the user doesn't configure a ping success regex, any ping with exitCode zero + // is good enough. Otherwise we check if either stdout or stderr have a match of + // the pingSuccessRegex. + final RegExp? pingSuccessRegex = hostPlatform.pingSuccessRegex; + + return pingSuccessRegex == null || + pingSuccessRegex.hasMatch(result.stdout) || + pingSuccessRegex.hasMatch(result.stderr); + } + + /// Creates a directory in the user's home directory + /// to store the snapp_cli related files like ssh keys + Future createSnappCliDirectory() async { + final String homePath = hostPlatform.homePath; + final String snappCliDirectoryPath = '$homePath/.snapp_cli'; + + final snappCliDirectory = Directory(snappCliDirectoryPath); + + if (!(await snappCliDirectory.exists())) { + await snappCliDirectory.create(); + } + + return snappCliDirectory; + } + + /// Generates a ssh key file in the snapp_cli directory + Future<({File privateKey, File publicKey})> generateSshKeyFile( + ProcessUtils processRunner, + Directory snappCliDir, + ) async { + // generate random 6 digit file name + final randomNumber = Random().nextInt(900000) + 100000; + + final keyFile = File('${snappCliDir.path}/id_rsa_$randomNumber'); + + final RunResult result; + try { + result = await processRunner.run( + hostPlatform.generateSshKeyCommand( + filePath: keyFile.path, + ), + timeout: Duration(seconds: 10), + ); + } catch (e, s) { + throwToolExit( + 'Something went wrong while generating the ssh key. \nException: $s \nStack: $s'); + } + + logger.printTrace('SSH Command ExitCode: ${result.exitCode}'); + logger.printTrace('SSH Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('SSH Command Stderr: ${result.stderr}'); + + if (result.exitCode != 0) { + throwToolExit('Something went wrong while generating the ssh key.'); + } + + return (privateKey: keyFile, publicKey: File('${keyFile.path}.pub')); + } + + /// Adds the ssh key to the ssh-agent + Future addSshKeyToAgent(File sshKey) async { + final RunResult result; + try { + result = await processRunner.run( + // TODO: add this to the hostPlatform + hostPlatform.commandRunner( + [ + 'ssh-add', + sshKey.path, + ], + ), + timeout: Duration(seconds: 10), + ); + } catch (e, s) { + throwToolExit( + 'Something went wrong while adding the key to ssh-agent. \nException: $s \nStack: $s'); + } + + logger.printTrace('ssh-add Command ExitCode: ${result.exitCode}'); + logger.printTrace('ssh-add Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('ssh-add Command Stderr: ${result.stderr}'); + + if (result.exitCode != 0) { + throwToolExit('Something went wrong while adding the key to ssh-agent.'); + } + } + + Future copySshKeyToRemote( + File sshKeyFile, + String username, + InternetAddress ip, + ) async { + final client = SSHClient( + await SSHSocket.connect( + ip.address, + 22, + timeout: Duration(seconds: 10), + ), + username: username, + onPasswordRequest: () { + stdout.write('Password: '); + stdin.echoMode = false; + return stdin.readLineSync() ?? exit(1); + }, + ); + + final session = await client.execute('cat >> .ssh/authorized_keys'); + await session.stdin.addStream(sshKeyFile.openRead().cast()); + + // Close the sink to send EOF to the remote process. + await session.stdin.close(); + + // Wait for session to exit to ensure all data is flushed to the remote process. + await session.done; + + client.close(); + + // You can get the exit code after the session is done + logger.printTrace('SSH Session ExitCode: ${session.exitCode}'); + logger.printTrace('SSH Session stdout: ${session.stdout}'); + logger.printTrace('SSH Session stderr: ${session.stderr}'); + } + + Future createPasswordLessSshConnection( + String username, + InternetAddress ip, + ) async { + final isDeviceReachable = await tryPingDevice( + ip.address, + ip.type == InternetAddressType.IPv6, + ); + + if (!isDeviceReachable) { + logger.printStatus( + 'Could not reach the device with the given IP-address.', + ); + + final continueWithoutPing = Confirm( + prompt: 'Do you want to continue anyway?', + defaultValue: true, // this is optional + waitForNewLine: true, // optional and will be false by default + ).interact(); + + if (!continueWithoutPing) { + logger.printSpaces(); + logger.printStatus('Check your device IP-address and try again.'); + + return false; + } + } + + logger.printSpaces(); + + final spinner = Spinner( + icon: '✔️', + leftPrompt: (done) => '', // prompts are optional + rightPrompt: (done) => done + // TODO: update the message + ? 'creating SSH connection completed.' + : 'creating SSH connection.', + ).interact(); + + // create a directory in the user's home directory + final snappCliDirectory = await createSnappCliDirectory(); + + final sshKeys = await generateSshKeyFile(processRunner, snappCliDirectory); + + await addSshKeyToAgent(sshKeys.privateKey); + + spinner.done(); + + await copySshKeyToRemote( + sshKeys.publicKey, + username, + ip, + ); + + logger.printSpaces(); + + logger.printStatus('PasswordLess SSH Connection created successfully.'); + + return true; + } + + /// Checks if the device is reachable via ssh + Future testPasswordLessSshConnection( + String username, + InternetAddress ip, + ) async { + final String sshTarget = (username.isNotEmpty ? '$username@' : '') + + (ip.type == InternetAddressType.IPv6 ? '[${ip.address}]' : ip.address); + + final spinner = Spinner( + icon: '✔️', + leftPrompt: (done) => '', // prompts are optional + rightPrompt: (done) => done + ? 'ssh connection completed.' + : 'trying to connect to the device via ssh.', + ).interact(); + + final RunResult result; + try { + result = await processRunner.run( + hostPlatform.sshCommand( + ipv6: ip.type == InternetAddressType.IPv6, + sshTarget: sshTarget, + command: 'echo "Test SSH Connection"', + lastCommand: true, + ), + timeout: Duration(seconds: 10), + ); + } catch (e, s) { + logger.printTrace( + 'Something went wrong while pinging the device. \nException: $s \nStack: $s', + ); + + return false; + } finally { + spinner.done(); + + logger.printSpaces(); + } + + logger.printTrace('SSH Test Command ExitCode: ${result.exitCode}'); + logger.printTrace('SSH Test Command Stdout: ${result.stdout.trim()}'); + logger.printTrace('SSH Test Command Stderr: ${result.stderr}'); + + logger.printSpaces(); + + if (result.exitCode != 0) { + return false; + } + + return true; + } +} diff --git a/lib/utils/common.dart b/lib/utils/common.dart index 9130184..5379c8b 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -1,3 +1,5 @@ +// ignore_for_file: implementation_imports + import 'dart:io'; import 'package:flutter_tools/src/base/logger.dart'; From c82953b5b62e3933ac9e2241656cbb72bebef8a6 Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Mon, 27 Nov 2023 13:26:50 +0330 Subject: [PATCH 7/9] create separate ssh subcommands --- lib/command_runner/command_runner.dart | 4 + lib/commands/base_command.dart | 33 ++ lib/commands/devices/devices_command.dart | 4 - lib/commands/devices/ssh_command.dart | 290 ------------------ .../ssh/create_connection_command.dart | 41 +++ lib/commands/ssh/ssh_command.dart | 22 ++ 6 files changed, 100 insertions(+), 294 deletions(-) delete mode 100644 lib/commands/devices/ssh_command.dart create mode 100644 lib/commands/ssh/create_connection_command.dart create mode 100644 lib/commands/ssh/ssh_command.dart diff --git a/lib/command_runner/command_runner.dart b/lib/command_runner/command_runner.dart index c4ad0fb..0908e92 100644 --- a/lib/command_runner/command_runner.dart +++ b/lib/command_runner/command_runner.dart @@ -2,6 +2,7 @@ import 'package:args/command_runner.dart'; import 'package:interact/interact.dart'; import 'package:snapp_cli/commands/devices/devices_command.dart'; +import 'package:snapp_cli/commands/ssh/ssh_command.dart'; import 'package:snapp_cli/utils/common.dart'; import 'package:snapp_cli/utils/flutter_sdk.dart'; import 'package:flutter_tools/src/runner/flutter_command_runner.dart'; @@ -26,6 +27,9 @@ class SnappCliCommandRunner extends CommandRunner { // Add the devices command to the command runner addCommand(DevicesCommand(flutterSdkManager: flutterSdkManager)); + + // Create and manage SSH connections + addCommand(SshCommand(flutterSdkManager: flutterSdkManager)); } final FlutterSdkManager flutterSdkManager; diff --git a/lib/commands/base_command.dart b/lib/commands/base_command.dart index 6e30e24..eca7724 100644 --- a/lib/commands/base_command.dart +++ b/lib/commands/base_command.dart @@ -1,9 +1,13 @@ // ignore_for_file: implementation_imports +import 'dart:io'; + import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/custom_devices/custom_devices_config.dart'; +import 'package:interact/interact.dart'; +import 'package:snapp_cli/utils/common.dart'; import 'package:snapp_cli/utils/flutter_sdk.dart'; abstract class BaseSnappCommand extends Command { @@ -16,6 +20,35 @@ abstract class BaseSnappCommand extends Command { CustomDevicesConfig get customDevicesConfig => flutterSdkManager.customDeviceConfig; Logger get logger => flutterSdkManager.logger; + + (InternetAddress ip, String username) getRemoteIpAndUsername( + {required String message}) { + logger.printSpaces(); + + logger.printStatus(message); + + final String deviceIp = Input( + prompt: 'Device IP-address:', + validator: (s) { + if (s.isValidIpAddress) { + return true; + } + throw ValidationError('Invalid IP-address. Please try again.'); + }, + ).interact(); + + final ip = InternetAddress(deviceIp); + + logger.printSpaces(); + + final String username = Input( + prompt: 'Username:', + ).interact(); + + logger.printSpaces(); + + return (ip, username); + } } extension ArgResultsExtension on ArgResults { diff --git a/lib/commands/devices/devices_command.dart b/lib/commands/devices/devices_command.dart index 10d4f1c..9997904 100644 --- a/lib/commands/devices/devices_command.dart +++ b/lib/commands/devices/devices_command.dart @@ -4,7 +4,6 @@ import 'package:snapp_cli/commands/base_command.dart'; import 'package:snapp_cli/commands/devices/add_command.dart'; import 'package:snapp_cli/commands/devices/delete_command.dart'; import 'package:snapp_cli/commands/devices/list_command.dart'; -import 'package:snapp_cli/commands/devices/ssh_command.dart'; import 'package:snapp_cli/commands/devices/update_ip_command.dart'; /// Add a new raspberry device to the Flutter SDK custom devices @@ -31,9 +30,6 @@ class DevicesCommand extends BaseSnappCommand { addSubcommand( UpdateIpCommand(flutterSdkManager: flutterSdkManager), ); - - addSubcommand(SshCommand(flutterSdkManager: flutterSdkManager)); - } @override diff --git a/lib/commands/devices/ssh_command.dart b/lib/commands/devices/ssh_command.dart deleted file mode 100644 index 60295c5..0000000 --- a/lib/commands/devices/ssh_command.dart +++ /dev/null @@ -1,290 +0,0 @@ -// ignore_for_file: implementation_imports - -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; - -import 'package:dartssh2/dartssh2.dart'; -import 'package:interact/interact.dart'; -import 'package:snapp_cli/commands/base_command.dart'; -import 'package:flutter_tools/src/base/common.dart'; -import 'package:flutter_tools/src/base/process.dart'; -import 'package:snapp_cli/host_runner/host_runner_platform.dart'; -import 'package:snapp_cli/utils/common.dart'; - -class SshCommand extends BaseSnappCommand { - SshCommand({ - required super.flutterSdkManager, - }) : hostPlatform = HostRunnerPlatform.build(flutterSdkManager.platform), - processRunner = ProcessUtils( - processManager: flutterSdkManager.processManager, - logger: flutterSdkManager.logger, - ); - - @override - String get description => 'Create an SSH connection to the remote device'; - - @override - String get name => 'ssh'; - - /// create a HostPlatform instance based on the current platform - /// with the help of this class we can make the commands platform specific - /// for example, the ping command is different on windows and linux - /// - /// only supports windows, linux and macos - final HostRunnerPlatform hostPlatform; - - final ProcessUtils processRunner; - - @override - FutureOr? run() async { - logger.printSpaces(); - - logger.printStatus( - 'to create an SSH connection to the remote device, we need an IP address and a username', - ); - - final String deviceIp = Input( - prompt: 'Device IP-address:', - validator: (s) { - if (s.isValidIpAddress) { - return true; - } - throw ValidationError('Invalid IP-address. Please try again.'); - }, - ).interact(); - - final ip = InternetAddress(deviceIp); - - logger.printSpaces(); - - final String username = Input( - prompt: 'Username:', - ).interact(); - - logger.printSpaces(); - - final isDeviceReachable = await _tryPingDevice( - deviceIp, - ip.type == InternetAddressType.IPv6, - ); - - if (!isDeviceReachable) { - logger.printStatus( - 'Could not reach the device with the given IP-address.', - ); - - final continueWithoutPing = Confirm( - prompt: 'Do you want to continue anyway?', - defaultValue: true, // this is optional - waitForNewLine: true, // optional and will be false by default - ).interact(); - - if (!continueWithoutPing) { - logger.printSpaces(); - logger.printStatus('Check your device IP-address and try again.'); - return 1; - } - } - - print('ip formatted: ${ip.address}'); - - logger.printSpaces(); - - final sshConnectionCreated = - await _createPasswordlessSshConnection(username, ip); - - return sshConnectionCreated ? 0 : 1; - } - - Future _tryPingDevice(String pingTarget, bool ipv6) async { - final spinner = Spinner( - icon: '✔️', - leftPrompt: (done) => '', // prompts are optional - rightPrompt: (done) => done - ? 'pinging device completed.' - : 'pinging device to check if it is reachable.', - ).interact(); - - await Future.delayed(Duration(seconds: 2)); - final RunResult result; - try { - result = await processRunner.run( - hostPlatform.pingCommand(ipv6: ipv6, pingTarget: pingTarget), - timeout: Duration(seconds: 10), - ); - } catch (e, s) { - logger.printTrace( - 'Something went wrong while trying to find flutter. \n $e \n $s', - ); - - return false; - } finally { - spinner.done(); - - logger.printSpaces(); - } - - logger.printTrace('Ping Command ExitCode: ${result.exitCode}'); - logger.printTrace('Ping Command Stdout: ${result.stdout.trim()}'); - logger.printTrace('Ping Command Stderr: ${result.stderr}'); - - logger.printSpaces(); - - if (result.exitCode != 0) { - return false; - } - - // If the user doesn't configure a ping success regex, any ping with exitCode zero - // is good enough. Otherwise we check if either stdout or stderr have a match of - // the pingSuccessRegex. - final RegExp? pingSuccessRegex = hostPlatform.pingSuccessRegex; - - return pingSuccessRegex == null || - pingSuccessRegex.hasMatch(result.stdout) || - pingSuccessRegex.hasMatch(result.stderr); - } - - Future _createPasswordlessSshConnection( - String username, - InternetAddress ip, - ) async { - final spinner = Spinner( - icon: '✔️', - leftPrompt: (done) => '', // prompts are optional - rightPrompt: (done) => done - ? 'creating SSH connection completed.' - : 'creating SSH connection.', - ).interact(); - - // create a directory in the user's home directory - final snappCliDirectory = await _createSnappCliDirectory(); - - final sshKeys = await _generateSshKeyFile(processRunner, snappCliDirectory); - - await _addSshKeyToAgent(sshKeys.privateKey); - - spinner.done(); - - final sshKeyCopied = await _copySshKeyToRemote( - sshKeys.publicKey, - username, - ip, - ); - - logger.printSpaces(); - - return sshKeyCopied; - } - - /// Creates a directory in the user's home directory - /// to store the snapp_cli related files like ssh keys - Future _createSnappCliDirectory() async { - final String homePath = hostPlatform.homePath; - final String snapppCliDirectoryPath = '$homePath/.snapp_cli'; - - final snappCliDirectory = Directory(snapppCliDirectoryPath); - - if (!(await snappCliDirectory.exists())) { - await snappCliDirectory.create(); - } - - return snappCliDirectory; - } - - /// Generates a ssh key file in the snapp_cli directory - Future<({File privateKey, File publicKey})> _generateSshKeyFile( - ProcessUtils processRunner, - Directory snappCliDir, - ) async { - // generate random 6 digit file name - final randomNumber = Random().nextInt(900000) + 100000; - - final keyFile = File('${snappCliDir.path}/id_rsa_$randomNumber'); - - final RunResult result; - try { - result = await processRunner.run( - hostPlatform.generateSshKeyCommand( - filePath: keyFile.path, - ), - timeout: Duration(seconds: 10), - ); - } catch (e, s) { - throwToolExit( - 'Something went wrong while generating the ssh key. \nException: $s \nStack: $s'); - } - - logger.printTrace('SSH Command ExitCode: ${result.exitCode}'); - logger.printTrace('SSH Command Stdout: ${result.stdout.trim()}'); - logger.printTrace('SSH Command Stderr: ${result.stderr}'); - - if (result.exitCode != 0) { - throwToolExit('Something went wrong while generating the ssh key.'); - } - - return (privateKey: keyFile, publicKey: File('${keyFile.path}.pub')); - } - - /// Adds the ssh key to the ssh-agent - Future _addSshKeyToAgent(File sshKey) async { - final RunResult result; - try { - result = await processRunner.run( - hostPlatform.commandRunner( - [ - 'ssh-add', - sshKey.path, - ], - ), - timeout: Duration(seconds: 10), - ); - } catch (e, s) { - throwToolExit( - 'Something went wrong while adding the key to ssh-agent. \nException: $s \nStack: $s'); - } - - logger.printTrace('ssh-add Command ExitCode: ${result.exitCode}'); - logger.printTrace('ssh-add Command Stdout: ${result.stdout.trim()}'); - logger.printTrace('ssh-add Command Stderr: ${result.stderr}'); - - if (result.exitCode != 0) { - throwToolExit('Something went wrong while adding the key to ssh-agent.'); - } - } - - Future _copySshKeyToRemote( - File sshKeyFile, - String username, - InternetAddress ip, - ) async { - final client = SSHClient( - await SSHSocket.connect( - ip.address, - 22, - timeout: Duration(seconds: 10), - ), - username: username, - onPasswordRequest: () { - stdout.write('Password: '); - stdin.echoMode = false; - return stdin.readLineSync() ?? exit(1); - }, - ); - - final session = await client.execute('cat >> .ssh/authorized_keys'); - await session.stdin.addStream(sshKeyFile.openRead().cast()); - - // Close the sink to send EOF to the remote process. - await session.stdin.close(); - - // Wait for session to exit to ensure all data is flushed to the remote process. - await session.done; - - // You can get the exit code after the session is done - print(session.exitCode); - - client.close(); - return true; - } -} diff --git a/lib/commands/ssh/create_connection_command.dart b/lib/commands/ssh/create_connection_command.dart new file mode 100644 index 0000000..d2c036d --- /dev/null +++ b/lib/commands/ssh/create_connection_command.dart @@ -0,0 +1,41 @@ +// ignore_for_file: implementation_imports + +import 'dart:async'; + +import 'package:snapp_cli/commands/base_command.dart'; +import 'package:snapp_cli/service/ssh_service.dart'; + +/// This command will create a PasswordLess SSH connection to the remote device. +/// +/// The user will be prompted for the IP-address and the username of the remote device. +/// +class CreateConnectionCommand extends BaseSnappCommand { + CreateConnectionCommand({ + required super.flutterSdkManager, + }) : _sshService = SshService(flutterSdkManager: flutterSdkManager); + + @override + String get description => + 'Create an PasswordLess SSH connection to the remote device'; + + @override + String get name => 'create-connection'; + + final SshService _sshService; + + @override + FutureOr? run() async { + final (ip, username) = getRemoteIpAndUsername( + message: + 'to create an SSH connection to the remote device, we need an IP address and a username', + ); + + final sshConnectionCreated = + await _sshService.createPasswordLessSshConnection( + username, + ip, + ); + + return sshConnectionCreated ? 0 : 1; + } +} diff --git a/lib/commands/ssh/ssh_command.dart b/lib/commands/ssh/ssh_command.dart new file mode 100644 index 0000000..0d026ca --- /dev/null +++ b/lib/commands/ssh/ssh_command.dart @@ -0,0 +1,22 @@ +// ignore_for_file: implementation_imports + +import 'package:snapp_cli/commands/base_command.dart'; +import 'package:snapp_cli/commands/ssh/create_connection_command.dart'; + +/// Add a new raspberry device to the Flutter SDK custom devices +class SshCommand extends BaseSnappCommand { + SshCommand({ + required super.flutterSdkManager, + }) { + // Create an SSH connection to the remote device + addSubcommand( + CreateConnectionCommand(flutterSdkManager: flutterSdkManager), + ); + } + + @override + final String description = 'Create and manage SSH connections'; + + @override + final String name = 'ssh'; +} From bcaa25aa7af200093cefb563f7bf958e0a8ccbf5 Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Mon, 27 Nov 2023 14:17:10 +0330 Subject: [PATCH 8/9] test connection command added --- .../ssh/create_connection_command.dart | 12 ++++- lib/commands/ssh/ssh_command.dart | 6 +++ lib/commands/ssh/test_connection_command.dart | 44 +++++++++++++++++++ lib/service/ssh_service.dart | 28 +++++------- lib/utils/common.dart | 16 +++++++ 5 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 lib/commands/ssh/test_connection_command.dart diff --git a/lib/commands/ssh/create_connection_command.dart b/lib/commands/ssh/create_connection_command.dart index d2c036d..9f1b53b 100644 --- a/lib/commands/ssh/create_connection_command.dart +++ b/lib/commands/ssh/create_connection_command.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:snapp_cli/commands/base_command.dart'; import 'package:snapp_cli/service/ssh_service.dart'; +import 'package:snapp_cli/utils/common.dart'; /// This command will create a PasswordLess SSH connection to the remote device. /// @@ -16,7 +17,7 @@ class CreateConnectionCommand extends BaseSnappCommand { @override String get description => - 'Create an PasswordLess SSH connection to the remote device'; + 'Create a PasswordLess SSH connection to the remote device'; @override String get name => 'create-connection'; @@ -36,6 +37,13 @@ class CreateConnectionCommand extends BaseSnappCommand { ip, ); - return sshConnectionCreated ? 0 : 1; + + if (sshConnectionCreated) { + logger.printSuccess('SSH connection to the remote device is created!'); + return 0; + } else { + logger.printFail('Could not create SSH connection to the remote device!'); + return 1; + } } } diff --git a/lib/commands/ssh/ssh_command.dart b/lib/commands/ssh/ssh_command.dart index 0d026ca..65abd32 100644 --- a/lib/commands/ssh/ssh_command.dart +++ b/lib/commands/ssh/ssh_command.dart @@ -2,6 +2,7 @@ import 'package:snapp_cli/commands/base_command.dart'; import 'package:snapp_cli/commands/ssh/create_connection_command.dart'; +import 'package:snapp_cli/commands/ssh/test_connection_command.dart'; /// Add a new raspberry device to the Flutter SDK custom devices class SshCommand extends BaseSnappCommand { @@ -12,6 +13,11 @@ class SshCommand extends BaseSnappCommand { addSubcommand( CreateConnectionCommand(flutterSdkManager: flutterSdkManager), ); + + // Test an SSH connection to the remote device + addSubcommand( + TestConnectionCommand(flutterSdkManager: flutterSdkManager), + ); } @override diff --git a/lib/commands/ssh/test_connection_command.dart b/lib/commands/ssh/test_connection_command.dart new file mode 100644 index 0000000..908b164 --- /dev/null +++ b/lib/commands/ssh/test_connection_command.dart @@ -0,0 +1,44 @@ +// ignore_for_file: implementation_imports + +import 'dart:async'; + +import 'package:snapp_cli/commands/base_command.dart'; +import 'package:snapp_cli/service/ssh_service.dart'; +import 'package:snapp_cli/utils/common.dart'; + +class TestConnectionCommand extends BaseSnappCommand { + TestConnectionCommand({ + required super.flutterSdkManager, + }) : _sshService = SshService(flutterSdkManager: flutterSdkManager); + + @override + String get description => + 'Test a PasswordLess SSH connection to the remote device'; + + @override + String get name => 'test-connection'; + + final SshService _sshService; + + @override + FutureOr? run() async { + final (ip, username) = getRemoteIpAndUsername( + message: + 'to test an SSH connection to the remote device, we need an IP address and a username', + ); + + final sshConnectionCreated = + await _sshService.testPasswordLessSshConnection( + username, + ip, + ); + + if (sshConnectionCreated) { + logger.printSuccess('SSH connection to the remote device is working!'); + return 0; + } else { + logger.printFail('SSH connection to the remote device is not working!'); + return 1; + } + } +} diff --git a/lib/service/ssh_service.dart b/lib/service/ssh_service.dart index f76127f..65d843b 100644 --- a/lib/service/ssh_service.dart +++ b/lib/service/ssh_service.dart @@ -47,7 +47,7 @@ class SshService { ); } catch (e, s) { logger.printTrace( - 'Something went wrong while pinging the device. \nException: $s \nStack: $s', + 'Something went wrong while pinging the device. \nException: $e \nStack: $s', ); return false; @@ -112,7 +112,7 @@ class SshService { ); } catch (e, s) { throwToolExit( - 'Something went wrong while generating the ssh key. \nException: $s \nStack: $s'); + 'Something went wrong while generating the ssh key. \nException: $e \nStack: $s'); } logger.printTrace('SSH Command ExitCode: ${result.exitCode}'); @@ -142,7 +142,7 @@ class SshService { ); } catch (e, s) { throwToolExit( - 'Something went wrong while adding the key to ssh-agent. \nException: $s \nStack: $s'); + 'Something went wrong while adding the key to ssh-agent. \nException: $e \nStack: $s'); } logger.printTrace('ssh-add Command ExitCode: ${result.exitCode}'); @@ -221,12 +221,10 @@ class SshService { logger.printSpaces(); final spinner = Spinner( - icon: '✔️', + icon: '🔎', leftPrompt: (done) => '', // prompts are optional - rightPrompt: (done) => done - // TODO: update the message - ? 'creating SSH connection completed.' - : 'creating SSH connection.', + rightPrompt: (done) => + done ? 'Search completed.' : 'Searching for the device', ).interact(); // create a directory in the user's home directory @@ -246,8 +244,6 @@ class SshService { logger.printSpaces(); - logger.printStatus('PasswordLess SSH Connection created successfully.'); - return true; } @@ -260,11 +256,10 @@ class SshService { (ip.type == InternetAddressType.IPv6 ? '[${ip.address}]' : ip.address); final spinner = Spinner( - icon: '✔️', + icon: '🔎', leftPrompt: (done) => '', // prompts are optional - rightPrompt: (done) => done - ? 'ssh connection completed.' - : 'trying to connect to the device via ssh.', + rightPrompt: (done) => + done ? 'Search completed.' : 'Searching for the device', ).interact(); final RunResult result; @@ -279,9 +274,10 @@ class SshService { timeout: Duration(seconds: 10), ); } catch (e, s) { - logger.printTrace( - 'Something went wrong while pinging the device. \nException: $s \nStack: $s', + logger.printStatus( + 'Something went wrong while trying to connect to the device via ssh. \nException: $e', ); + logger.printTrace('Stack: $s'); return false; } finally { diff --git a/lib/utils/common.dart b/lib/utils/common.dart index 5379c8b..1c3f09a 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter_tools/src/base/logger.dart'; +import 'package:interact/interact.dart'; final RegExp hostnameRegex = RegExp( r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'); @@ -17,9 +18,24 @@ extension StringExt on String { } extension LoggerExt on Logger { + String get successIcon => Theme.colorfulTheme.successPrefix; + String get errorIcon => Theme.colorfulTheme.errorPrefix; + void printSpaces([int count = 1]) { for (var i = 0; i < count; i++) { print(''); } } + + void printSuccess(String message) { + printStatus( + successIcon + Theme.colorfulTheme.messageStyle(message), + ); + } + + void printFail(String message) { + printStatus( + errorIcon + Theme.colorfulTheme.messageStyle(message), + ); + } } From 8295866c5afac96f3f28c5efd9fbd9f9db2f018d Mon Sep 17 00:00:00 2001 From: Payam Zahedi Date: Mon, 27 Nov 2023 16:52:52 +0330 Subject: [PATCH 9/9] add create ssh connection to add command --- lib/command_runner/command_runner.dart | 2 +- lib/commands/base_command.dart | 17 +- lib/commands/devices/add_command.dart | 237 ++++++++---------- .../ssh/create_connection_command.dart | 6 +- lib/commands/ssh/test_connection_command.dart | 6 +- lib/service/ssh_service.dart | 2 +- lib/utils/common.dart | 8 + 7 files changed, 131 insertions(+), 147 deletions(-) diff --git a/lib/command_runner/command_runner.dart b/lib/command_runner/command_runner.dart index 0908e92..0d9ea7a 100644 --- a/lib/command_runner/command_runner.dart +++ b/lib/command_runner/command_runner.dart @@ -82,7 +82,7 @@ flutter config --enable-custom-devices --enable-linux-desktop Future _enableConfigs() async { final spinner = Spinner( - icon: '✔️', + icon: logger.successIcon, leftPrompt: (done) => '', // prompts are optional rightPrompt: (done) => done ? 'Configs enabled successfully!' diff --git a/lib/commands/base_command.dart b/lib/commands/base_command.dart index eca7724..f7e4fe1 100644 --- a/lib/commands/base_command.dart +++ b/lib/commands/base_command.dart @@ -21,12 +21,20 @@ abstract class BaseSnappCommand extends Command { flutterSdkManager.customDeviceConfig; Logger get logger => flutterSdkManager.logger; - (InternetAddress ip, String username) getRemoteIpAndUsername( - {required String message}) { + (InternetAddress ip, String username) getRemoteIpAndUsername({ + required String message, + String? getIpDescription, + String? getUsernameDescription, + }) { logger.printSpaces(); logger.printStatus(message); + if (getIpDescription != null) { + logger.printStatus(getIpDescription); + logger.printSpaces(); + } + final String deviceIp = Input( prompt: 'Device IP-address:', validator: (s) { @@ -41,6 +49,11 @@ abstract class BaseSnappCommand extends Command { logger.printSpaces(); + if (getUsernameDescription != null) { + logger.printStatus(getUsernameDescription); + logger.printSpaces(); + } + final String username = Input( prompt: 'Username:', ).interact(); diff --git a/lib/commands/devices/add_command.dart b/lib/commands/devices/add_command.dart index fc9a1bc..38bf2fe 100644 --- a/lib/commands/devices/add_command.dart +++ b/lib/commands/devices/add_command.dart @@ -10,6 +10,7 @@ import 'package:interact/interact.dart'; import 'package:snapp_cli/commands/base_command.dart'; import 'package:snapp_cli/configs/predefined_devices.dart'; import 'package:snapp_cli/host_runner/host_runner_platform.dart'; +import 'package:snapp_cli/service/ssh_service.dart'; import 'package:snapp_cli/utils/common.dart'; import 'package:flutter_tools/src/base/common.dart'; @@ -20,7 +21,8 @@ import 'package:flutter_tools/src/base/common.dart'; class AddCommand extends BaseSnappCommand { AddCommand({ required super.flutterSdkManager, - }) : hostPlatform = HostRunnerPlatform.build(flutterSdkManager.platform); + }) : hostPlatform = HostRunnerPlatform.build(flutterSdkManager.platform), + sshService = SshService(flutterSdkManager: flutterSdkManager); /// create a HostPlatform instance based on the current platform /// with the help of this class we can make the commands platform specific @@ -29,6 +31,8 @@ class AddCommand extends BaseSnappCommand { /// only supports windows, linux and macos final HostRunnerPlatform hostPlatform; + final SshService sshService; + @override final name = 'add'; @@ -88,114 +92,70 @@ class AddCommand extends BaseSnappCommand { Future _addCustomDevice({ CustomDeviceConfig? predefinedConfig, }) async { - /// path to the icu data file on the host machine - final hostIcuDataPath = flutterSdkManager.icuDataPath; - - /// path to the build artifacts on the remote machine - const hostBuildClonePath = 'snapp_embedded'; - - /// path to the icu data file on the remote machine - const hostIcuDataClone = '$hostBuildClonePath/engine'; - logger.printSpaces(); - String id = predefinedConfig?.id ?? ''; - - if (id.isEmpty) { - logger.printStatus( - 'Please enter the id you want to device to have. Must contain only alphanumeric or underscore characters. (example: pi)', - ); + // get remote device id and label from the user + final (id, label) = getRemoteDeviceIdAndLabel(predefinedConfig); - id = Input( - prompt: 'Device Id:', - validator: (s) { - if (!RegExp(r'^\w+$').hasMatch(s.trim())) { - throw ValidationError('Invalid input. Please try again.'); - } else if (_isDuplicatedDeviceId(s.trim())) { - throw ValidationError('Device with this id already exists.'); - } - return true; - }, - ).interact().trim(); - - logger.printSpaces(); - } - - String label = predefinedConfig?.label ?? ''; - - if (label.isEmpty) { - logger.printStatus( - 'Please enter the label of the device, which is a slightly more verbose name for the device. (example: Raspberry Pi Model 4B)', - ); - label = Input( - prompt: 'Device label:', - validator: (s) { - if (s.trim().isNotEmpty) { - return true; - } - throw ValidationError('Input is empty. Please try again.'); - }, - ).interact(); - - logger.printSpaces(); - } - - logger.printStatus( - 'Please enter the IP-address of the device. (example: 192.168.1.101)', + // get remote device ip and username from the user + final (targetIp, username) = getRemoteIpAndUsername( + message: 'to add a new device, we need an IP address and a username.', + getIpDescription: + 'Please enter the IP-address of the device. (example: 192.168.1.101)', + getUsernameDescription: + 'Please enter the username used for ssh-ing into the remote device. (example: pi)', ); - final String targetStr = Input( - prompt: 'Device IP-address:', - validator: (s) { - if (s.isValidIpAddress) { - return true; - } - throw ValidationError('Invalid IP-address. Please try again.'); - }, - ).interact(); + final bool ipv6 = targetIp.isIpv6; - final InternetAddress? targetIp = InternetAddress.tryParse(targetStr); - final bool useIp = targetIp != null; - final bool ipv6 = useIp && targetIp.type == InternetAddressType.IPv6; final InternetAddress loopbackIp = ipv6 ? InternetAddress.loopbackIPv6 : InternetAddress.loopbackIPv4; - logger.printSpaces(); - - logger.printStatus( - 'Please enter the username used for ssh-ing into the remote device. (example: pi)', - ); - - final String username = Input( - prompt: 'Device Username:', - ).interact(); - // SSH expects IPv6 addresses to use the bracket syntax like URIs do too, // but the IPv6 the user enters is a raw IPv6 address, so we need to wrap it. final String sshTarget = (username.isNotEmpty ? '$username@' : '') + - (ipv6 ? '[${targetIp.address}]' : targetStr); + (ipv6 ? '[${targetIp.address}]' : targetIp.address); final String formattedLoopbackIp = ipv6 ? '[${loopbackIp.address}]' : loopbackIp.address; logger.printSpaces(); - final isDeviceReachable = await _tryPingDevice(targetStr, ipv6); + bool remoteHasSshConnection = + await sshService.testPasswordLessSshConnection(username, targetIp); - if (!isDeviceReachable) { - logger.printStatus( - 'Could not reach the device with the given IP-address.', + if (!remoteHasSshConnection) { + logger.printFail( + 'could not establish a password-less ssh connection to the remote device. \n', ); + logger.printStatus( + 'We can create a ssh connection with the remote device, do you want to try it?'); + final continueWithoutPing = Confirm( - prompt: 'Do you want to continue anyway?', + prompt: 'Create a ssh connection?', defaultValue: true, // this is optional waitForNewLine: true, // optional and will be false by default ).interact(); if (!continueWithoutPing) { logger.printSpaces(); - logger.printStatus('Check your device IP-address and try again.'); + logger.printStatus( + 'Check your ssh connection with the remote device and try again.'); + return 1; + } + + logger.printSpaces(); + + final sshConnectionCreated = + await sshService.createPasswordLessSshConnection(username, targetIp); + + if (sshConnectionCreated) { + logger.printSuccess('SSH connection to the remote device is created!'); + remoteHasSshConnection = true; + } else { + logger + .printFail('Could not create SSH connection to the remote device!'); return 1; } } @@ -209,7 +169,7 @@ class AddCommand extends BaseSnappCommand { String remoteRunnerCommand = ''; - if (isDeviceReachable) { + if (remoteHasSshConnection) { final possibleFlutterPath = await _findFlutterPath(sshTarget, ipv6); remoteRunnerCommand = possibleFlutterPath ?? ''; @@ -235,6 +195,15 @@ class AddCommand extends BaseSnappCommand { ).interact(); } + /// path to the icu data file on the host machine + final hostIcuDataPath = flutterSdkManager.icuDataPath; + + /// path to the build artifacts on the remote machine + const hostBuildClonePath = 'snapp_embedded'; + + /// path to the icu data file on the remote machine + const hostIcuDataClone = '$hostBuildClonePath/engine'; + CustomDeviceConfig config = CustomDeviceConfig( id: id, label: label, @@ -242,7 +211,8 @@ class AddCommand extends BaseSnappCommand { enabled: true, // host-platform specific, filled out later - pingCommand: hostPlatform.pingCommand(ipv6: ipv6, pingTarget: targetStr), + pingCommand: + hostPlatform.pingCommand(ipv6: ipv6, pingTarget: targetIp.address), pingSuccessRegex: hostPlatform.pingSuccessRegex, postBuildCommand: const [], @@ -347,6 +317,52 @@ class AddCommand extends BaseSnappCommand { return 0; } + (String id, String label) getRemoteDeviceIdAndLabel( + CustomDeviceConfig? predefinedConfig, + ) { + String id = predefinedConfig?.id ?? ''; + String label = predefinedConfig?.label ?? ''; + + if (id.isEmpty) { + logger.printStatus( + 'Please enter the id you want to device to have. Must contain only alphanumeric or underscore characters. (example: pi)', + ); + + id = Input( + prompt: 'Device Id:', + validator: (s) { + if (!RegExp(r'^\w+$').hasMatch(s.trim())) { + throw ValidationError('Invalid input. Please try again.'); + } else if (_isDuplicatedDeviceId(s.trim())) { + throw ValidationError('Device with this id already exists.'); + } + return true; + }, + ).interact().trim(); + + logger.printSpaces(); + } + + if (label.isEmpty) { + logger.printStatus( + 'Please enter the label of the device, which is a slightly more verbose name for the device. (example: Raspberry Pi Model 4B)', + ); + label = Input( + prompt: 'Device label:', + validator: (s) { + if (s.trim().isNotEmpty) { + return true; + } + throw ValidationError('Input is empty. Please try again.'); + }, + ).interact(); + + logger.printSpaces(); + } + + return (id, label); + } + bool _isDuplicatedDeviceId(String s) { return customDevicesConfig.devices.any((element) => element.id == s); } @@ -366,65 +382,12 @@ class AddCommand extends BaseSnappCommand { return '$s-$i'; } - Future _tryPingDevice(String pingTarget, bool ipv6) async { - final spinner = Spinner( - icon: '✔️', - leftPrompt: (done) => '', // prompts are optional - rightPrompt: (done) => done - ? 'pinging device completed.' - : 'pinging device to check if it is reachable.', - ).interact(); - - final processRunner = ProcessUtils( - processManager: flutterSdkManager.processManager, - logger: logger, - ); - - await Future.delayed(Duration(seconds: 2)); - final RunResult result; - try { - result = await processRunner.run( - hostPlatform.pingCommand(ipv6: ipv6, pingTarget: pingTarget), - timeout: Duration(seconds: 10), - ); - } catch (e, s) { - logger.printTrace( - 'Something went wrong while trying to ping the device. \n $e \n $s', - ); - - return false; - } finally { - spinner.done(); - - logger.printSpaces(); - } - - logger.printTrace('Ping Command ExitCode: ${result.exitCode}'); - logger.printTrace('Ping Command Stdout: ${result.stdout.trim()}'); - logger.printTrace('Ping Command Stderr: ${result.stderr}'); - - logger.printSpaces(); - - if (result.exitCode != 0) { - return false; - } - - // If the user doesn't configure a ping success regex, any ping with exitCode zero - // is good enough. Otherwise we check if either stdout or stderr have a match of - // the pingSuccessRegex. - final RegExp? pingSuccessRegex = hostPlatform.pingSuccessRegex; - - return pingSuccessRegex == null || - pingSuccessRegex.hasMatch(result.stdout) || - pingSuccessRegex.hasMatch(result.stderr); - } - /// finds flutter in the host using ssh connection /// returns the path of flutter if found it /// otherwise returns null Future _findFlutterPath(String sshTarget, bool ipv6) async { final spinner = Spinner( - icon: '✔️', + icon: logger.successIcon, leftPrompt: (done) => '', // prompts are optional rightPrompt: (done) => done ? 'finding flutter path completed' diff --git a/lib/commands/ssh/create_connection_command.dart b/lib/commands/ssh/create_connection_command.dart index 9f1b53b..6916c15 100644 --- a/lib/commands/ssh/create_connection_command.dart +++ b/lib/commands/ssh/create_connection_command.dart @@ -13,7 +13,7 @@ import 'package:snapp_cli/utils/common.dart'; class CreateConnectionCommand extends BaseSnappCommand { CreateConnectionCommand({ required super.flutterSdkManager, - }) : _sshService = SshService(flutterSdkManager: flutterSdkManager); + }) : sshService = SshService(flutterSdkManager: flutterSdkManager); @override String get description => @@ -22,7 +22,7 @@ class CreateConnectionCommand extends BaseSnappCommand { @override String get name => 'create-connection'; - final SshService _sshService; + final SshService sshService; @override FutureOr? run() async { @@ -32,7 +32,7 @@ class CreateConnectionCommand extends BaseSnappCommand { ); final sshConnectionCreated = - await _sshService.createPasswordLessSshConnection( + await sshService.createPasswordLessSshConnection( username, ip, ); diff --git a/lib/commands/ssh/test_connection_command.dart b/lib/commands/ssh/test_connection_command.dart index 908b164..212b29b 100644 --- a/lib/commands/ssh/test_connection_command.dart +++ b/lib/commands/ssh/test_connection_command.dart @@ -9,7 +9,7 @@ import 'package:snapp_cli/utils/common.dart'; class TestConnectionCommand extends BaseSnappCommand { TestConnectionCommand({ required super.flutterSdkManager, - }) : _sshService = SshService(flutterSdkManager: flutterSdkManager); + }) : sshService = SshService(flutterSdkManager: flutterSdkManager); @override String get description => @@ -18,7 +18,7 @@ class TestConnectionCommand extends BaseSnappCommand { @override String get name => 'test-connection'; - final SshService _sshService; + final SshService sshService; @override FutureOr? run() async { @@ -28,7 +28,7 @@ class TestConnectionCommand extends BaseSnappCommand { ); final sshConnectionCreated = - await _sshService.testPasswordLessSshConnection( + await sshService.testPasswordLessSshConnection( username, ip, ); diff --git a/lib/service/ssh_service.dart b/lib/service/ssh_service.dart index 65d843b..a2401eb 100644 --- a/lib/service/ssh_service.dart +++ b/lib/service/ssh_service.dart @@ -31,7 +31,7 @@ class SshService { Future tryPingDevice(String pingTarget, bool ipv6) async { final spinner = Spinner( - icon: '✔️', + icon: logger.successIcon, leftPrompt: (done) => '', // prompts are optional rightPrompt: (done) => done ? 'pinging device completed.' diff --git a/lib/utils/common.dart b/lib/utils/common.dart index 1c3f09a..6f557ee 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -39,3 +39,11 @@ extension LoggerExt on Logger { ); } } + + +extension IpExt on InternetAddress { + String get ipAddress => address; + + bool get isIpv4 => type == InternetAddressType.IPv4; + bool get isIpv6 => type == InternetAddressType.IPv6; +} \ No newline at end of file