diff --git a/bin/sshnp.dart b/bin/sshnp.dart index c999e4919..5fd69d808 100644 --- a/bin/sshnp.dart +++ b/bin/sshnp.dart @@ -1,490 +1,24 @@ // dart packages -import 'dart:async'; import 'dart:io'; // atPlatform packages -import 'package:at_client/at_client.dart'; import 'package:at_utils/at_logger.dart'; -import 'package:at_onboarding_cli/at_onboarding_cli.dart'; - -// external packages -import 'package:args/args.dart'; -import 'package:logging/logging.dart'; -import 'package:uuid/uuid.dart'; -import 'package:version/version.dart'; // local packages -import 'package:sshnoports/version.dart'; -import 'package:sshnoports/home_directory.dart'; -import 'package:sshnoports/check_non_ascii.dart'; +import 'package:sshnoports/sshnp.dart'; import 'package:sshnoports/cleanup_sshnp.dart'; -import 'package:sshnoports/check_file_exists.dart'; void main(List args) async { - final AtSignLogger logger = AtSignLogger(' sshnp '); - logger.hierarchicalLoggingEnabled = true; - logger.logger.level = Level.SHOUT; + AtSignLogger.root_level = 'SHOUT'; - var uuid = Uuid(); - String sessionId = uuid.v4(); + SSHNP sshnp = await SSHNP.fromCommandLineArgs(args); ProcessSignal.sigint.watch().listen((signal) async { - await cleanUp(sessionId, logger); + await cleanUp(sshnp.sessionId, sshnp.logger); exit(1); }); - - // Get location of running sshnp so - // sshrv can be run even if sshnp was found - // by a PATH location rather than fully qualified - // *Note sshnp and sshrv need to be compiled to exe before use as result. - String sshnpDir = Platform.resolvedExecutable; - String pathSeparator = Platform.pathSeparator; - List pathList = sshnpDir.split(pathSeparator); - pathList.removeLast(); - sshnpDir = pathList.join(pathSeparator) + pathSeparator; - - var parser = ArgParser(); - // Basic arguments - parser.addOption('key-file', - abbr: 'k', - mandatory: false, - help: 'Sending atSign\'s atKeys file if not in ~/.atsign/keys/'); - parser.addOption('from', abbr: 'f', mandatory: true, help: 'Sending atSign'); - parser.addOption('to', - abbr: 't', mandatory: true, help: 'Send a notification to this atSign'); - parser.addOption('device', - abbr: 'd', - mandatory: false, - defaultsTo: "default", - help: 'Send a notification to this device'); - parser.addOption('host', - abbr: 'h', - mandatory: true, - help: 'atSign of sshrvd daemon or FQDN/IP address to connect back to '); - parser.addOption('port', - abbr: 'p', - mandatory: false, - defaultsTo: '22', - help: - 'TCP port to connect back to (only required if --host specified a FQDN/IP)'); - parser.addOption('local-port', - abbr: 'l', - defaultsTo: '0', - mandatory: false, - help: - 'Reverse ssh port to listen on, on your local machine, by sshnp default finds a spare port'); - parser.addOption('ssh-public-key', - abbr: 's', - defaultsTo: 'false', - mandatory: false, - help: - 'Public key file from ~/.ssh to be appended to authorized_hosts on the remote device'); - parser.addMultiOption('local-ssh-options', - abbr: 'o', help: 'Add these commands to the local ssh command'); - parser.addFlag('verbose', abbr: 'v', help: 'More logging'); - parser.addFlag('rsa', - abbr: 'r', - defaultsTo: false, - help: 'Use RSA 4096 keys rather than the default ED25519 keys'); - - // Check the arguments - late AtClient? atClient; - dynamic results; - String? username; - String atsignFile; - String fromAtsign = 'unknown'; - String toAtsign = 'unknown'; - String? homeDirectory = getHomeDirectory(); - String device = ""; - String nameSpace = ''; - String sshrvdNameSpace = 'sshrvd'; - String port; - String sshrvdPort = ''; - String host = "127.0.0.1"; - String localPort; - String sshString = ""; - String sshHomeDirectory = ""; - String sendSshPublicKey = ""; - List localSshOptions = []; - int counter = 0; - bool ack = false; - bool ackErrors = false; - bool rsa = false; - // In the future (perhaps) we can send other commands - // Perhaps OpenVPN or shell commands - String sendCommand = 'sshd'; - - try { - // Arg check - results = parser.parse(args); - - // Do we have a username ? - Map envVars = Platform.environment; - if (Platform.isLinux || Platform.isMacOS) { - username = envVars['USER']; - } else if (Platform.isWindows) { - username = envVars['USERPROFILE']; - } - if (username == null) { - throw ('\nUnable to determine your username: please set environment variable\n\n'); - } - if (homeDirectory == null) { - throw ('\nUnable to determine your home directory: please set environment variable\n\n'); - } - // Setup ssh keys location - sshHomeDirectory = "$homeDirectory/.ssh/"; - if (Platform.isWindows) { - sshHomeDirectory = '$homeDirectory\\.ssh\\'; - } - - // Find atSign key file - fromAtsign = results['from']; - toAtsign = results['to']; - if (results['key-file'] != null) { - atsignFile = results['key-file']; - } else { - atsignFile = '${fromAtsign}_key.atKeys'; - atsignFile = '$homeDirectory/.atsign/keys/$atsignFile'; - } - // Check atKeyFile selected exists - if (!await fileExists(atsignFile)) { - throw ('\n Unable to find .atKeys file : $atsignFile'); - } - - // Get the other easy options - host = results['host']; - port = results['port']; - localPort = results['local-port']; - localSshOptions = results['local-ssh-options']; - - // Check device string only contains ascii - if (checkNonAscii(results['device'])) { - throw ('\nDevice name can only contain alphanumeric characters with a max length of 15'); - } - - // Add a namespace separator just cause its neater. - device = results['device'] + "."; - rsa = results['rsa']; - nameSpace = '${device}sshnp'; - - // Check the public key if the option was selected - sendSshPublicKey = results['ssh-public-key']; - if ((sendSshPublicKey != 'false')) { - sendSshPublicKey = '$sshHomeDirectory$sendSshPublicKey'; - if (!await fileExists(sendSshPublicKey)) { - throw ('\n Unable to find ssh public key file : $sendSshPublicKey'); - } - if (!sendSshPublicKey.endsWith('.pub')) { - throw ('\n The ssh public key should have a ".pub" extension'); - } - } - } catch (e) { - version(); - stdout.writeln(parser.usage); - stderr.writeln(e); - exit(1); - } - if (rsa) { - await Process.run('ssh-keygen', - ['-t', 'rsa', '-b', '4096', '-f', '${sessionId}_sshnp', '-q', '-N', ''], - workingDirectory: sshHomeDirectory); - } else { - await Process.run( - 'ssh-keygen', - [ - '-t', - 'ed25519', - '-a', - '100', - '-f', - '${sessionId}_sshnp', - '-q', - '-N', - '' - ], - workingDirectory: sshHomeDirectory); - } - String sshPublicKey = - await File('$sshHomeDirectory${sessionId}_sshnp.pub').readAsString(); - String sshPrivateKey = - await File('$sshHomeDirectory${sessionId}_sshnp').readAsString(); - - // Set up a safe authorized_keys file, for the reverse ssh tunnel - File('${sshHomeDirectory}authorized_keys').writeAsStringSync( - 'command="echo \\"ssh session complete\\";sleep 20",PermitOpen="localhost:22" ${sshPublicKey.trim()} $sessionId\n', - mode: FileMode.append); - - // Now on to the atPlatform startup - AtSignLogger.root_level = 'SHOUT'; - if (results['verbose']) { - logger.logger.level = Level.INFO; - - AtSignLogger.root_level = 'INFO'; - } - - //onboarding preference builder can be used to set onboardingService parameters - AtOnboardingPreference atOnboardingConfig = AtOnboardingPreference() - ..hiveStoragePath = '/tmp/.sshnp/$fromAtsign/$sessionId/storage' - ..namespace = '${device}sshnp' - ..downloadPath = '/tmp/.sshnp/files' - ..isLocalStoreRequired = true - ..commitLogPath = - '$homeDirectory/.sshnp/$fromAtsign/$sessionId/storage/commitLog' - ..fetchOfflineNotifications = false - ..atKeysFilePath = atsignFile - ..atProtocolEmitted = Version(2, 0, 0); - - AtOnboardingService onboardingService = - AtOnboardingServiceImpl(fromAtsign, atOnboardingConfig); - - await onboardingService.authenticate(); - - atClient = AtClientManager.getInstance().atClient; - - NotificationService notificationService = atClient.notificationService; - - notificationService - .subscribe(regex: '$sessionId.$nameSpace@', shouldDecrypt: true) - .listen(((notification) async { - String notificationKey = notification.key - .replaceAll('${notification.to}:', '') - .replaceAll('.$device.sshnp${notification.from}', '') - // convert to lower case as the latest AtClient converts notification - // keys to lower case when received - .toLowerCase(); - logger.info('Received $notificationKey notification'); - if (notification.value == 'connected') { - logger.info('Session $sessionId connected successfully'); - // Give ssh/sshd a little time to get everything in place - // await Future.delayed(Duration(milliseconds: 250)); - ack = true; - } else { - stderr.writeln('Remote sshnpd error: ${notification.value}'); - ack = true; - ackErrors = true; - } - })); - - var metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..namespaceAware = true; - - var atKey = AtKey() - ..key = "username" - ..sharedBy = toAtsign - ..sharedWith = fromAtsign - ..namespace = nameSpace - ..metadata = metaData; - AtValue? toAtsignUsername; - try { - toAtsignUsername = await atClient.get(atKey); - } catch (e) { - stderr.writeln( - "Device \"${device.replaceAll('.', '')}\" unknown or username not shared"); - await cleanUp(sessionId, logger); - exit(1); - } - var remoteUsername = toAtsignUsername.value; - - // If host has an @ then contact the sshrvd service for some ports - if (host.startsWith('@')) { - String sshrvdId = uuid.v4(); - metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..namespaceAware = false; - - atKey = AtKey() - ..key = '$sshrvdId.$sshrvdNameSpace' - ..sharedBy = host - ..sharedWith = fromAtsign - ..metadata = metaData; - - atClient.notificationService - .subscribe(regex: '$sshrvdId.$sshrvdNameSpace@', shouldDecrypt: true) - .listen((notification) async { - String ipPorts = notification.value.toString(); - List results = ipPorts.split(','); - host = results[0]; - port = results[1]; - sshrvdPort = results[2]; - ack = true; - }); - - metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..namespaceAware = false - ..ttr = -1 - ..ttl = 10000; - - atKey = AtKey() - ..key = '$device$sshrvdNameSpace' - ..sharedBy = fromAtsign - ..sharedWith = host - ..metadata = metaData; - - try { - await notificationService - .notify(NotificationParams.forUpdate(atKey, value: sshrvdId), - onSuccess: (notification) { - logger.info('SUCCESS:$notification $sshString'); - }, onError: (notification) { - logger.info('ERROR:$notification $sshString'); - }); - } catch (e) { - stderr.writeln(e.toString()); - } - - while (!ack) { - await Future.delayed(Duration(milliseconds: 100)); - counter++; - if (counter == 100) { - ack = true; - await cleanUp(sessionId, logger); - stderr.writeln('sshnp: connection timeout to sshrvd $host service'); - exit(1); - } - } - ack = false; -// Connect to rz point using background process -// This way this program can exit - unawaited(Process.run('$sshnpDir/sshrv', [host, sshrvdPort])); - } - - metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..namespaceAware = true - ..ttr = -1 - ..ttl = 10000; - - var key = AtKey() - ..key = 'privatekey' - ..sharedBy = fromAtsign - ..sharedWith = toAtsign - ..namespace = nameSpace - ..metadata = metaData; - - try { - await notificationService - .notify(NotificationParams.forUpdate(key, value: sshPrivateKey), - onSuccess: (notification) { - logger.info('SUCCESS:$notification'); - }, onError: (notification) { - logger.info('ERROR:$notification'); - }); - } catch (e) { - stderr.writeln(e.toString()); - } - - metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..namespaceAware = true - ..ttr = -1 - ..ttl = 10000; - - key = AtKey() - ..key = 'sshpublickey' - ..sharedBy = fromAtsign - ..sharedWith = toAtsign - ..metadata = metaData; - - if (sendSshPublicKey != 'false') { - try { - String toSshPublicKey = await File(sendSshPublicKey).readAsString(); - if (!toSshPublicKey.startsWith('ssh-')) { - throw ('$sshHomeDirectory$sendSshPublicKey does not look like a public key file'); - } - await notificationService - .notify(NotificationParams.forUpdate(key, value: toSshPublicKey), - onSuccess: (notification) { - logger.info('SUCCESS:$notification'); - }, onError: (notification) { - logger.info('ERROR:$notification'); - }); - } catch (e) { - stderr.writeln( - "Error opening or validating public key file or sending to remote atSign: $e"); - await cleanUp(sessionId, logger); - exit(1); - } - } - - // find a spare local port - if (localPort == '0') { - ServerSocket serverSocket = - await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - localPort = serverSocket.port.toString(); - await serverSocket.close(); - } - - metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..namespaceAware = true - ..ttr = -1 - ..ttl = 10000; - - key = AtKey() - ..key = sendCommand - ..sharedBy = fromAtsign - ..sharedWith = toAtsign - ..metadata = metaData; - - if (sendCommand == 'sshd') { - // Local port, port of sshd , username , hostname - sshString = '$localPort $port $username $host $sessionId'; - } - - try { - await notificationService - .notify(NotificationParams.forUpdate(key, value: sshString), - onSuccess: (notification) { - logger.info('SUCCESS:$notification $sshString'); - }, onError: (notification) { - logger.info('ERROR:$notification $sshString'); - }); - } catch (e) { - stderr.writeln(e.toString()); - } - - // Before we clean up we need to make sure that the reverse ssh made the connection. - // Or that if it had a problem what the problem was, or timeout and explain why. - - // Timer to timeout after 10 Secs or after the Ack of connected/Errors - while (!ack) { - await Future.delayed(Duration(milliseconds: 100)); - counter++; - if (counter == 300) { - ack = true; - await cleanUp(sessionId, logger); - stderr.writeln('sshnp: connection timeout'); - exit(1); - } - } - // Clean Up the files we created - await cleanUp(sessionId, logger); + await sshnp.init(); - // print out base ssh command if we hit no Ack Errors - // If we had a Public key include the private key in the command line - // By removing the .pub extn - if (!ackErrors) { - if (sendSshPublicKey != 'false') { - stdout.write( - "ssh -p $localPort $remoteUsername@localhost -i ${sendSshPublicKey.replaceFirst(RegExp(r'.pub$'), '')} "); - } else { - stdout.write("ssh -p $localPort $remoteUsername@localhost "); - } - // print out optional arguments - for (var argument in localSshOptions) { - stdout.write("$argument "); - } - } - // Print the return - stdout.write('\n'); - exit(0); + await sshnp.run(); } diff --git a/bin/sshnpd.dart b/bin/sshnpd.dart index 7d21b3000..56d2f0c09 100644 --- a/bin/sshnpd.dart +++ b/bin/sshnpd.dart @@ -16,9 +16,7 @@ import 'package:version/version.dart'; // local packages import 'package:sshnoports/version.dart'; -import 'package:sshnoports/home_directory.dart'; -import 'package:sshnoports/check_non_ascii.dart'; -import 'package:sshnoports/check_file_exists.dart'; +import 'package:sshnoports/sshnp_utils.dart'; void main(List args) async { try { diff --git a/bin/sshrvd.dart b/bin/sshrvd.dart index d420c4975..06208f6d7 100644 --- a/bin/sshrvd.dart +++ b/bin/sshrvd.dart @@ -16,8 +16,7 @@ import 'package:version/version.dart'; // local packages import 'package:sshnoports/version.dart'; -import 'package:sshnoports/home_directory.dart'; -import 'package:sshnoports/check_file_exists.dart'; +import 'package:sshnoports/sshnp_utils.dart'; void main(List args) async { final AtSignLogger logger = AtSignLogger(' sshrvd '); diff --git a/lib/check_file_exists.dart b/lib/check_file_exists.dart deleted file mode 100644 index c6e8a789f..000000000 --- a/lib/check_file_exists.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -Future fileExists(String file) async { - bool f = await File(file).exists(); - return f; -} diff --git a/lib/check_non_ascii.dart b/lib/check_non_ascii.dart deleted file mode 100644 index c33edf26b..000000000 --- a/lib/check_non_ascii.dart +++ /dev/null @@ -1,8 +0,0 @@ -bool checkNonAscii(String test) { - var extra = test.replaceAll(RegExp(r'[a-zA-Z0-9_]*'), ''); - if ((extra != '') || (test.length > 15)) { - return true; - } else { - return false; - } -} diff --git a/lib/cleanup_sshnp.dart b/lib/cleanup_sshnp.dart index 38cff7574..584c798be 100644 --- a/lib/cleanup_sshnp.dart +++ b/lib/cleanup_sshnp.dart @@ -1,7 +1,7 @@ // dart packages import 'dart:io'; // local packages -import 'package:sshnoports/home_directory.dart'; +import 'package:sshnoports/sshnp_utils.dart'; // atPlatform packages import 'package:at_utils/at_logger.dart'; diff --git a/lib/home_directory.dart b/lib/home_directory.dart deleted file mode 100644 index 0962b658e..000000000 --- a/lib/home_directory.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:io'; - -// Get the home directory or null if unknown. -String? getHomeDirectory() { - switch (Platform.operatingSystem) { - case 'linux': - case 'macos': - return Platform.environment['HOME']; - case 'windows': - return Platform.environment['USERPROFILE']; - case 'android': - // Probably want internal storage. - return '/storage/sdcard0'; - case 'ios': - // iOS doesn't really have a home directory. - return null; - case 'fuchsia': - // I have no idea. - return null; - default: - return null; - } -} diff --git a/lib/sshnp.dart b/lib/sshnp.dart new file mode 100644 index 000000000..d0790e052 --- /dev/null +++ b/lib/sshnp.dart @@ -0,0 +1,693 @@ +// dart packages +import 'dart:async'; +import 'dart:io'; + +// atPlatform packages +import 'package:at_utils/at_logger.dart'; +import 'package:at_client/at_client.dart'; +import 'package:at_onboarding_cli/at_onboarding_cli.dart'; + +// other packages +import 'package:args/args.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:uuid/uuid.dart'; +import 'package:version/version.dart'; + +// local packages +import 'package:sshnoports/service_factories.dart'; +import 'package:sshnoports/sshnp_utils.dart'; +import 'package:sshnoports/cleanup_sshnp.dart'; +import 'package:sshnoports/version.dart'; + +class SSHNP { + // TODO Make this a const in SSHRVD class + static const String sshrvdNameSpace = 'sshrvd'; + + final AtSignLogger logger = AtSignLogger(' sshnp '); + + // ==================================================================== + // Final instance variables, injected via constructor + // ==================================================================== + /// The [AtClient] used to communicate with sshnpd and sshrvd + final AtClient atClient; + + /// The atSign of the sshnpd we wish to communicate with + final String sshnpdAtSign; + + /// The device name of the sshnpd we wish to communicate with + final String device; + + /// The user name on this host + final String username; + + /// The home directory on this host + final String homeDirectory; + + /// The sessionId we will use + final String sessionId; + + final String sendSshPublicKey; + final List localSshOptions; + + /// When false, we generate [sshPublicKey] and [sshPrivateKey] using ed25519. + /// When true, we generate [sshPublicKey] and [sshPrivateKey] using RSA. + /// Defaults to false + final bool rsa; + + // ==================================================================== + // Volatile instance variables, injected via constructor + // but possibly modified later on + // ==================================================================== + + /// Host that we will send to sshnpd for it to connect to, + /// or the atSign of the sshrvd. + /// If using sshrvd then we will fetch the _actual_ host to use from sshrvd. + String host; + + /// Port that we will send to sshnpd for it to connect to. + /// Required if we are not using sshrvd. + /// If using sshrvd then initial port value will be ignored and instead we + /// will fetch the port from sshrvd. + String port; + + /// Port to which sshnpd will forwardRemote its [SSHClient]. If localPort + /// is set to '0' then + String localPort; + + // ==================================================================== + // Derived final instance variables, set during construction or init + // ==================================================================== + + /// Set to [AtClient.getCurrentAtSign] during construction + @visibleForTesting + late final String clientAtSign; + + /// The username to use on the remote host in the ssh session. Either passed + /// through class constructor or fetched from the sshnpd + /// by [fetchRemoteUserName] during [init] + String? remoteUsername; + + /// Set by [generateSshKeys] during [init]. + /// sshnp generates a new keypair for each ssh session, using ed25519 by + /// default but rsa if the [rsa] flag is set to true. sshnp will write + /// [sshPublicKey] to ~/.ssh/authorized_keys + late final String sshPublicKey; + + /// Set by [generateSshKeys] during [init]. + /// sshnp generates a new keypair for each ssh session, using ed25519 by + /// default but rsa if the [rsa] flag is set to true. sshnp will send the + /// [sshPrivateKey] to sshnpd + late final String sshPrivateKey; + + /// Namespace will be set to [device].sshnp + late final String nameSpace; + + /// When using sshrvd, this is fetched from sshrvd during [init] + late final String sshrvdPort; + + /// Set to '$localPort $port $username $host $sessionId' during [init] + late final String sshString; + + /// Set by constructor to + /// '$homeDirectory${Platform.pathSeparator}.ssh${Platform.pathSeparator}' + late final String sshHomeDirectory; + + /// true once we have received any response (success or error) from sshnpd + @visibleForTesting + bool sshnpdAck = false; + + /// true once we have received an error response from sshnpd + @visibleForTesting + bool sshnpdAckErrors = false; + + /// true once we have received a response from sshrvd + @visibleForTesting + bool sshrvdAck = false; + + // In the future (perhaps) we can send other commands + // Perhaps OpenVPN or shell commands + static const String commandToSend = 'sshd'; + + /// true once [init] has completed + @visibleForTesting + bool initialized = false; + + SSHNP({ + // final fields + required this.atClient, + required this.sshnpdAtSign, + required this.device, + required this.username, + required this.homeDirectory, + required this.sessionId, + this.sendSshPublicKey = 'false', + required this.localSshOptions, + this.rsa = false, + // volatile fields + required this.host, + required this.port, + required this.localPort, + this.remoteUsername, + }) { + nameSpace = '$device.sshnp'; + clientAtSign = atClient.getCurrentAtSign()!; + logger.hierarchicalLoggingEnabled = true; + logger.logger.level = Level.SHOUT; + + sshHomeDirectory = getDefaultSshDirectory(homeDirectory); + if (! Directory(sshHomeDirectory).existsSync()) { + Directory(sshHomeDirectory).createSync(); + } + } + + /// Must be run after construction, to complete initialization + /// - Starts notification subscription to listen for responses from sshnpd + /// - calls [generateSshKeys] which generates the ssh keypair to use + /// ( [sshPublicKey] and [sshPrivateKey] ) + /// - calls [fetchRemoteUserName] to fetch the username to use on the remote + /// host in the ssh session + /// - If not supplied via constructor, finds a spare port for [localPort] + /// - If using sshrv, calls [getHostAndPortFromSshrvd] to fetch host and port + /// from sshrvd + /// - calls [sharePrivateKeyWithSshnpd] + /// - calls [sharePublicKeyWithSshnpdIfRequired] + Future init() async { + if (initialized) { + throw StateError('Cannot init() - already initialized'); + } + + logger.info('Subscribing to notifications on $sessionId.$nameSpace@'); + // Start listening for response notifications from sshnpd + atClient.notificationService + .subscribe(regex: '$sessionId.$nameSpace@', shouldDecrypt: true) + .listen(handleSshnpdResponses); + + await generateSshKeys(); + + if (remoteUsername == null) { + await fetchRemoteUserName(); + } + + // find a spare local port + if (localPort == '0') { + ServerSocket serverSocket = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + localPort = serverSocket.port.toString(); + await serverSocket.close(); + } + + // If host has an @ then contact the sshrvd service for some ports + if (host.startsWith('@')) { + await getHostAndPortFromSshrvd(); + } + + if (commandToSend == 'sshd') { + // Local port, port of sshd , username , hostname + sshString = '$localPort $port $username $host $sessionId'; + } + + await sharePrivateKeyWithSshnpd(); + + await sharePublicKeyWithSshnpdIfRequired(); + + initialized = true; + } + + /// May only be run after [init] has been run. + /// - Sends request to sshnpd; the response listener was started by [init] + /// - Waits for success or error response, or time out after 10 secs + /// - If got a success response, print the ssh command to use to stdout + /// - Clean up temporary files + Future run() async { + if (!initialized) { + throw StateError('Cannot run() - not initialized'); + } + AtKey keyForCommandToSend = AtKey() + ..key = commandToSend + ..namespace = nameSpace + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000); + + try { + await atClient.notificationService + .notify(NotificationParams.forUpdate(keyForCommandToSend, value: sshString), + onSuccess: (notification) { + logger.info('SUCCESS:$notification $sshString'); + }, onError: (notification) { + logger.info('ERROR:$notification $sshString'); + }); + } catch (e) { + stderr.writeln(e.toString()); + } + + // Before we clean up we need to make sure that the reverse ssh made the connection. + // Or that if it had a problem what the problem was, or timeout and explain why. + + int counter = 0; + // Timer to timeout after 10 Secs or after the Ack of connected/Errors + while (!sshnpdAck) { + await Future.delayed(Duration(milliseconds: 100)); + counter++; + if (counter == 100) { + await cleanUp(sessionId, logger); + stderr.writeln('sshnp: connection timeout'); + exit(1); + } + } + + // Clean Up the files we created + await cleanUp(sessionId, logger); + + // print out base ssh command if we hit no Ack Errors + // If we had a Public key include the private key in the command line + // By removing the .pub extn + if (!sshnpdAckErrors) { + if (sendSshPublicKey != 'false') { + stdout.write( + "ssh -p $localPort $remoteUsername@localhost -i ${sendSshPublicKey.replaceFirst(RegExp(r'.pub$'), '')} "); + } else { + stdout.write("ssh -p $localPort $remoteUsername@localhost "); + } + // print out optional arguments + for (var argument in localSshOptions) { + stdout.write("$argument "); + } + } + // Print the return + stdout.write('\n'); + exit(0); + } + + /// Function which the response subscription (created in the [init] method + /// will call when it gets a response from the sshnpd + @visibleForTesting + handleSshnpdResponses(notification) async { + String notificationKey = notification.key + .replaceAll('${notification.to}:', '') + .replaceAll('.$device.sshnp${notification.from}', '') + // convert to lower case as the latest AtClient converts notification + // keys to lower case when received + .toLowerCase(); + logger.info('Received $notificationKey notification'); + if (notification.value == 'connected') { + logger.info('Session $sessionId connected successfully'); + sshnpdAck = true; + } else { + stderr.writeln('Remote sshnpd error: ${notification.value}'); + sshnpdAck = true; + sshnpdAckErrors = true; + } + } + + /// Look up the user name ... we expect a key to have been shared with us by + /// sshnpd. Let's say we are @human running sshnp, and @daemon is running + /// sshnpd, then we expect a key to have been shared whose ID is + /// @human:username.device.sshnp@daemon + /// Is not called if remoteUserName was set via constructor + Future fetchRemoteUserName() async { + AtKey userNameRecordID = AtKey.fromString('$clientAtSign:username.$nameSpace$sshnpdAtSign'); + try { + remoteUsername = (await atClient.get(userNameRecordID)).value as String; + } catch (e) { + stderr.writeln("Device \"$device\" unknown, or username not shared "); + await cleanUp(sessionId, logger); + exit(1); + } + } + + Future sharePublicKeyWithSshnpdIfRequired() async { + if (sendSshPublicKey != 'false') { + try { + String toSshPublicKey = await File(sendSshPublicKey).readAsString(); + if (!toSshPublicKey.startsWith('ssh-')) { + throw ('$sshHomeDirectory$sendSshPublicKey does not look like a public key file'); + } + AtKey sendOurPublicKeyToSshnpd = AtKey() + ..key = 'sshpublickey' + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000); + await atClient.notificationService.notify( + NotificationParams.forUpdate(sendOurPublicKeyToSshnpd, + value: toSshPublicKey), onSuccess: (notification) { + logger.info('SUCCESS:$notification'); + }, onError: (notification) { + logger.info('ERROR:$notification'); + }); + } catch (e) { + stderr.writeln( + "Error opening or validating public key file or sending to remote atSign: $e"); + await cleanUp(sessionId, logger); + exit(1); + } + } + } + + Future sharePrivateKeyWithSshnpd() async { + AtKey sendOurPrivateKeyToSshnpd = AtKey() + ..key = 'privatekey' + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..namespace = nameSpace + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000); + + try { + await atClient.notificationService + .notify(NotificationParams.forUpdate(sendOurPrivateKeyToSshnpd, value: sshPrivateKey), + onSuccess: (notification) { + logger.info('SUCCESS:$notification'); + }, onError: (notification) { + logger.info('ERROR:$notification'); + }); + } catch (e) { + stderr.writeln(e.toString()); + } + } + + Future getHostAndPortFromSshrvd() async { + atClient.notificationService + .subscribe(regex: '$sessionId.$sshrvdNameSpace@', shouldDecrypt: true) + .listen((notification) async { + String ipPorts = notification.value.toString(); + List results = ipPorts.split(','); + host = results[0]; + port = results[1]; + sshrvdPort = results[2]; + sshrvdAck = true; + }); + + AtKey ourSshrvdIdKey = AtKey() + ..key = '$device.$sshrvdNameSpace' + ..sharedBy = clientAtSign // shared by us + ..sharedWith = host // shared with the sshrvd host + ..metadata = (Metadata() + // as we are sending a notification to the sshrvd namespace, + // we don't want to append our namespace + ..namespaceAware = false + ..ttr = -1 + ..ttl = 10000); + + try { + await atClient.notificationService + .notify(NotificationParams.forUpdate(ourSshrvdIdKey, value: sessionId), + onSuccess: (notification) { + logger.info('SUCCESS:$notification $ourSshrvdIdKey'); + }, onError: (notification) { + logger.info('ERROR:$notification $ourSshrvdIdKey'); + }); + } catch (e) { + stderr.writeln(e.toString()); + } + + int counter = 0; + while (!sshrvdAck) { + await Future.delayed(Duration(milliseconds: 100)); + counter++; + if (counter == 100) { + await cleanUp(sessionId, logger); + stderr.writeln('sshnp: connection timeout to sshrvd $host service'); + exit(1); + } + } + + // Connect to rendezvous point using background process. + // sshnp (this program) can then exit without issue. + unawaited(Process.run(getSshrvCommand(), [host, sshrvdPort])); + } + + Future generateSshKeys() async { + if (rsa) { + await Process.run( + 'ssh-keygen', + [ + '-t', + 'rsa', + '-b', + '4096', + '-f', + '${sessionId}_sshnp', + '-q', + '-N', + '' + ], + workingDirectory: sshHomeDirectory); + } else { + await Process.run( + 'ssh-keygen', + [ + '-t', + 'ed25519', + '-a', + '100', + '-f', + '${sessionId}_sshnp', + '-q', + '-N', + '' + ], + workingDirectory: sshHomeDirectory); + } + + sshPublicKey = + await File('$sshHomeDirectory${sessionId}_sshnp.pub').readAsString(); + sshPrivateKey = + await File('$sshHomeDirectory${sessionId}_sshnp').readAsString(); + + // Set up a safe authorized_keys file, for the reverse ssh tunnel + File('${sshHomeDirectory}authorized_keys').writeAsStringSync( + 'command="echo \\"ssh session complete\\";sleep 20",PermitOpen="localhost:22" ${sshPublicKey.trim()} $sessionId\n', + mode: FileMode.append); + } + + static SSHNPParams parseSSHNPParams(List args) { + var p = SSHNPParams(); + + // Arg check + ArgResults r = createArgParser().parse(args); + + // Do we have a username ? + p.username = getUserName(throwIfNull:true)!; + + // Do we have a 'home' directory? + p.homeDirectory = getHomeDirectory(throwIfNull:true)!; + + p.clientAtSign = r['from']; + p.sshnpdAtSign = r['to']; + + // Find atSign key file + if (r['key-file'] != null) { + p.atKeysFilePath = r['key-file']; + } else { + p.atKeysFilePath = getDefaultAtKeysFilePath(p.homeDirectory, p.clientAtSign); + } + + // Check device string only contains ascii + if (checkNonAscii(r['device'])) { + throw ('\nDevice name can only contain alphanumeric characters with a max length of 15'); + } + + p.device = r['device']; + + // Check the public key if the option was selected + var sendSshPublicKey = r['ssh-public-key']; + if ((sendSshPublicKey != 'false')) { + sendSshPublicKey = '${getDefaultSshDirectory(p.homeDirectory)}$sendSshPublicKey'; + if (!sendSshPublicKey.endsWith('.pub')) { + throw ('\n The ssh public key should have a ".pub" extension'); + } + } + p.sendSshPublicKey = sendSshPublicKey; + + p.host = r['host']; + p.port = r['port']; + p.localPort = r['local-port']; + + p.localSshOptions = r['local-ssh-options']; + + p.rsa = r['rsa']; + p.verbose = r['verbose']; + + p.remoteUsername = r['remote-user-name']; + + return p; + } + + static Future fromCommandLineArgs(List args) async { + try { + var p = parseSSHNPParams(args); + + // Check atKeyFile selected exists + if (!File(p.atKeysFilePath).existsSync()) { + throw ('\n Unable to find .atKeys file : ${p.atKeysFilePath}'); + } + + if (!File(p.sendSshPublicKey).existsSync()) { + throw ('\n Unable to find ssh public key file : ${p.sendSshPublicKey}'); + } + + String sessionId = Uuid().v4(); + + if (p.verbose) { + AtSignLogger.root_level = 'INFO'; + } + + AtClient atClient = await createAtClient( + clientAtSign: p.clientAtSign, + device: p.device, + sessionId: sessionId, + atKeysFilePath: p.atKeysFilePath); + + var sshnp = SSHNP( + atClient: atClient, + sshnpdAtSign: p.sshnpdAtSign, + username: p.username, + homeDirectory: p.homeDirectory, + sessionId: sessionId, + device: p.device, + host: p.host, + port: p.port, + localPort: p.localPort, + localSshOptions: p.localSshOptions, + rsa: p.rsa, + sendSshPublicKey: p.sendSshPublicKey, + remoteUsername: p.remoteUsername, + ); + if (p.verbose) { + sshnp.logger.logger.level = Level.INFO; + } + + return sshnp; + } catch (e) { + version(); + stdout.writeln(createArgParser().usage); + stderr.writeln(e); + exit(1); + } + } + + static Future createAtClient( + {required String clientAtSign, + required String device, + required String sessionId, + required String atKeysFilePath}) async { + // Now on to the atPlatform startup + //onboarding preference builder can be used to set onboardingService parameters + AtOnboardingPreference atOnboardingConfig = AtOnboardingPreference() + ..hiveStoragePath = '/tmp/.sshnp/$clientAtSign/$sessionId/storage' + .replaceAll('/', Platform.pathSeparator) + ..namespace = '$device.sshnp' + ..downloadPath = + '/tmp/.sshnp/files'.replaceAll('/', Platform.pathSeparator) + ..isLocalStoreRequired = true + ..commitLogPath = '/tmp/.sshnp/$clientAtSign/$sessionId/storage/commitLog' + .replaceAll('/', Platform.pathSeparator) + ..fetchOfflineNotifications = false + ..atKeysFilePath = atKeysFilePath + ..atProtocolEmitted = Version(2, 0, 0); + + AtOnboardingService onboardingService = AtOnboardingServiceImpl( + clientAtSign, atOnboardingConfig, + atServiceFactory: ServiceFactoryWithNoOpSyncService()); + + await onboardingService.authenticate(); + + return AtClientManager.getInstance().atClient; + } + + static ArgParser createArgParser() { + var parser = ArgParser(); + // Basic arguments + parser.addOption('key-file', + abbr: 'k', + mandatory: false, + help: 'Sending atSign\'s atKeys file if not in ~/.atsign/keys/'); + parser.addOption('from', + abbr: 'f', mandatory: true, help: 'Sending atSign'); + parser.addOption('to', + abbr: 't', mandatory: true, help: 'Send a notification to this atSign'); + parser.addOption('device', + abbr: 'd', + mandatory: false, + defaultsTo: "default", + help: 'Send a notification to this device'); + parser.addOption('host', + abbr: 'h', + mandatory: true, + help: 'atSign of sshrvd daemon or FQDN/IP address to connect back to '); + parser.addOption('port', + abbr: 'p', + mandatory: false, + defaultsTo: '22', + help: + 'TCP port to connect back to (only required if --host specified a FQDN/IP)'); + parser.addOption('local-port', + abbr: 'l', + defaultsTo: '0', + mandatory: false, + help: + 'Reverse ssh port to listen on, on your local machine, by sshnp default finds a spare port'); + parser.addOption('ssh-public-key', + abbr: 's', + defaultsTo: 'false', + mandatory: false, + help: + 'Public key file from ~/.ssh to be appended to authorized_hosts on the remote device'); + parser.addMultiOption('local-ssh-options', + abbr: 'o', help: 'Add these commands to the local ssh command'); + parser.addFlag('verbose', abbr: 'v', help: 'More logging'); + parser.addFlag('rsa', + abbr: 'r', + defaultsTo: false, + help: 'Use RSA 4096 keys rather than the default ED25519 keys'); + parser.addOption('remote-user-name', + abbr: 'u', + mandatory: false, + help: 'user name to use in the ssh session on the remote host'); + return parser; + } + + /// Return the command which this program should execute in order to start the + /// sshrv program. + /// - In normal usage, sshnp and sshrv are compiled to exe before use, thus the + /// path is [Platform.resolvedExecutable] but with the last part (`sshnp` in + /// this case) replaced with `sshrv` + static String getSshrvCommand() { + late String sshnpDir; + if (Platform.executable.endsWith('${Platform.pathSeparator}sshnp')) { + List pathList = + Platform.resolvedExecutable.split(Platform.pathSeparator); + pathList.removeLast(); + sshnpDir = pathList.join(Platform.pathSeparator) + Platform.pathSeparator; + + return '$sshnpDir${Platform.pathSeparator}sshrv'; + } else { + throw Exception( + 'sshnp is expected to be run as a compiled executable, not via the dart command'); + } + } +} + +class SSHNPParams { + late final String clientAtSign; + late final String sshnpdAtSign; + late final String device; + late final String host; + late final String port; + late final String localPort; + late final String username; + late final String homeDirectory; + late final String atKeysFilePath; + late final String sendSshPublicKey; + late final List localSshOptions; + late final bool rsa; + late final bool verbose; + late final String? remoteUsername; +} \ No newline at end of file diff --git a/lib/sshnp_utils.dart b/lib/sshnp_utils.dart new file mode 100644 index 000000000..e30a063e1 --- /dev/null +++ b/lib/sshnp_utils.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +/// Get the home directory or null if unknown. +String? getHomeDirectory({bool throwIfNull = false}) { + String? homeDir; + switch (Platform.operatingSystem) { + case 'linux': + case 'macos': + homeDir = Platform.environment['HOME']; + case 'windows': + homeDir = Platform.environment['USERPROFILE']; + case 'android': + // Probably want internal storage. + homeDir = '/storage/sdcard0'; + case 'ios': + // iOS doesn't really have a home directory. + case 'fuchsia': + // I have no idea. + default: + homeDir = null; + } + if (throwIfNull && homeDir == null) { + throw ('\nUnable to determine your home directory: please set environment variable\n\n'); + } + return homeDir; +} + +/// Get the local username or null if unknown +String? getUserName({bool throwIfNull = false}) { + Map envVars = Platform.environment; + if (Platform.isLinux || Platform.isMacOS) { + return envVars['USER']; + } else if (Platform.isWindows) { + return envVars['USERPROFILE']; + } + if (throwIfNull) { + throw ('\nUnable to determine your username: please set environment variable\n\n'); + } + return null; +} + +Future fileExists(String file) async { + bool f = await File(file).exists(); + return f; +} + +bool checkNonAscii(String test) { + var extra = test.replaceAll(RegExp(r'[a-zA-Z0-9_]*'), ''); + if ((extra != '') || (test.length > 15)) { + return true; + } else { + return false; + } +} + +String getDefaultAtKeysFilePath(String homeDirectory, String atSign) { +return '$homeDirectory/.atsign/keys/${atSign}_key.atKeys' + .replaceAll('/', Platform.pathSeparator); +} + +String getDefaultSshDirectory(String homeDirectory) { +return '$homeDirectory/.ssh/' + .replaceAll('/', Platform.pathSeparator); +} diff --git a/lib/version.dart b/lib/version.dart index 9b548ed26..8c76b0a39 100644 --- a/lib/version.dart +++ b/lib/version.dart @@ -1,6 +1,6 @@ import 'dart:io'; -//Print version number +/// Print version number void version() { final String version = "3.3.0"; stdout.writeln('Version : $version'); diff --git a/pubspec.yaml b/pubspec.yaml index 648f7f177..dc57fd316 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,9 @@ dependencies: logging: 1.2.0 version: 3.0.2 socket_connector: 1.0.8 + meta: ^1.9.1 dev_dependencies: - lints: ">=1.0.0 <3.0.0" + lints: ^2.1.1 + test: ^1.24.3 + mocktail: ^0.3.0 diff --git a/test/sshnp_test.dart b/test/sshnp_test.dart new file mode 100644 index 000000000..552505003 --- /dev/null +++ b/test/sshnp_test.dart @@ -0,0 +1,92 @@ +import 'package:args/args.dart'; +import 'package:sshnoports/sshnp.dart'; +import 'package:sshnoports/sshnp_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('args parser tests', () { + test('test mandatory args', () { + ArgParser parser = SSHNP.createArgParser(); + // As of version 2.4.2 of the args package, exceptions regarding + // mandatory options are not thrown when the args are parsed, + // but when trying to retrieve a mandatory option. + // See https://pub.dev/packages/args/changelog + + List args = []; + expect(() => parser.parse(args)['from'], throwsA(isA())); + + args.addAll(['-f','@alice']); + expect(parser.parse(args)['from'], '@alice'); + expect(() => parser.parse(args)['to'], throwsA(isA())); + + args.addAll(['-t','@bob']); + expect(parser.parse(args)['from'], '@alice'); + expect(parser.parse(args)['to'], '@bob'); + expect(() => parser.parse(args)['host'], throwsA(isA())); + + args.addAll(['-h','host.subdomain.test']); + expect(parser.parse(args)['from'], '@alice'); + expect(parser.parse(args)['to'], '@bob'); + expect(parser.parse(args)['host'], 'host.subdomain.test'); + }); + + test('test parsed args with only mandatory provided', () { + List args = []; + args.addAll(['-f', '@alice']); + args.addAll(['-t', '@bob']); + args.addAll(['-h', 'host.subdomain.test']); + var p = SSHNP.parseSSHNPParams(args); + expect(p.clientAtSign, '@alice'); + expect(p.sshnpdAtSign, '@bob'); + expect(p.host, 'host.subdomain.test'); + + expect(p.device, 'default'); + expect(p.port, '22'); + expect(p.localPort, '0'); + expect(p.username, getUserName(throwIfNull: true)); + expect(p.homeDirectory, getHomeDirectory(throwIfNull:true)); + expect(p.atKeysFilePath, getDefaultAtKeysFilePath(p.homeDirectory, p.clientAtSign)); + expect(p.sendSshPublicKey, 'false'); + expect(p.localSshOptions, []); + expect(p.rsa, false); + expect(p.verbose, false); + expect(p.remoteUsername, null); + }); + + test('test parsed args with non-mandatory args provided', () { + List args = []; + args.addAll(['-f', '@alice']); + args.addAll(['-t', '@bob']); + args.addAll(['-h', 'host.subdomain.test']); + + + args.addAll([ + '--device','ancient_pc', + '--port','56789', + '--local-port','98765', + '--key-file','/tmp/temp_keys.json', + '--ssh-public-key','sekrit.pub', + '--local-ssh-options','--arg 2 --arg 4 foo bar -x', + '--remote-user-name','gary', + '-v', + '-r' + ]); + var p = SSHNP.parseSSHNPParams(args); + expect(p.clientAtSign, '@alice'); + expect(p.sshnpdAtSign, '@bob'); + expect(p.host, 'host.subdomain.test'); + + expect(p.device, 'ancient_pc'); + expect(p.port, '56789'); + expect(p.localPort, '98765'); + expect(p.username, getUserName(throwIfNull: true)); + expect(p.homeDirectory, getHomeDirectory(throwIfNull:true)); + expect(p.atKeysFilePath, '/tmp/temp_keys.json'); + expect(p.sendSshPublicKey, '${getDefaultSshDirectory(p.homeDirectory)}sekrit.pub'); + expect(p.localSshOptions, ['--arg 2 --arg 4 foo bar -x']); + expect(p.rsa, true); + expect(p.verbose, true); + expect(p.remoteUsername, 'gary'); + }); + }); +} \ No newline at end of file