diff --git a/bin/sshnpd.dart b/bin/sshnpd.dart index f9ac6e8f0..a3b96bd7c 100644 --- a/bin/sshnpd.dart +++ b/bin/sshnpd.dart @@ -1,408 +1,21 @@ // 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:dartssh2/dartssh2.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/sshnp_utils.dart'; +import 'package:sshnoports/sshnpd.dart'; void main(List args) async { + + SSHNPD sshnpd = await SSHNPD.fromCommandLineArgs(args); + try { - await _main(args); + await sshnpd.init(); + + await sshnpd.run(); } catch (error, stackTrace) { stderr.writeln('sshnpd: ${error.toString()}'); stderr.writeln('stack trace: ${stackTrace.toString()}'); await stderr.flush().timeout(Duration(milliseconds: 100)); exit(1); } -} - -Future _main(List args) async { - final AtSignLogger logger = AtSignLogger(' sshnpd '); - late AtClient? atClient; - String nameSpace = ''; - - var parser = ArgParser(); - // Basic arguments - parser.addOption('keyFile', - abbr: 'k', - mandatory: false, - help: 'Sending atSign\'s keyFile if not in ~/.atsign/keys/'); - parser.addOption('atsign', - abbr: 'a', mandatory: true, help: 'atSign of this device'); - parser.addOption('manager', - abbr: 'm', - mandatory: true, - help: 'Managers atSign, that this device will accept triggers from'); - parser.addOption('device', - abbr: 'd', - mandatory: false, - defaultsTo: "default", - help: - 'Send a trigger to this device, allows multiple devices share an atSign'); - - parser.addFlag('sshpublickey', - abbr: 's', - help: 'Update authorized_keys to include public key from sshnp'); - parser.addFlag('username', - abbr: 'u', - help: - 'Send username to the manager to allow sshnp to display username in command line'); - parser.addFlag('verbose', abbr: 'v', help: 'More logging'); - - // Check the arguments - dynamic results; - String username = "nobody"; - String atsignFile; - String deviceAtsign = 'unknown'; - String device = ""; - String managerAtsign = 'unknown'; - String? homeDirectory = getHomeDirectory(); - - try { - // Arg check - results = parser.parse(args); - - // Do we have a username ? - Map envVars = Platform.environment; - if (Platform.isLinux || Platform.isMacOS) { - username = envVars['USER'].toString(); - } else if (Platform.isWindows) { - username = envVars['\$env:username'].toString(); - } - if (username == 'nobody') { - 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'); - } - if (checkNonAscii(results['device'])) { - throw ('\nDevice name can only contain alphanumeric characters with a max length of 15'); - } - device = results['device']; - - // Find atSign key file - if (results['keyFile'] != null) { - atsignFile = results['keyFile']; - } else { - deviceAtsign = results['atsign']; - managerAtsign = results['manager']; - atsignFile = '${deviceAtsign}_key.atKeys'; - } - atsignFile = '$homeDirectory/.atsign/keys/$atsignFile'; - // Check atKeyFile selected exists - if (!await fileExists(atsignFile)) { - throw ('\n Unable to find .atKeys file : $atsignFile'); - } - } catch (e) { - (e); - version(); - stdout.writeln(parser.usage); - exit(0); - } - - logger.hierarchicalLoggingEnabled = true; - logger.logger.level = Level.SHOUT; - - 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() - //..qrCodePath = 'etc/qrcode_blueamateurbinding.png' - ..hiveStoragePath = '$homeDirectory/.sshnp/$deviceAtsign/storage' - ..namespace = 'sshnp' - ..downloadPath = '$homeDirectory/.sshnp/files' - ..isLocalStoreRequired = true - ..commitLogPath = '$homeDirectory/.sshnp/$deviceAtsign/storage/commitLog' - //..cramSecret = ''; - ..fetchOfflineNotifications = false - ..atKeysFilePath = atsignFile - ..atProtocolEmitted = Version(2, 0, 0); - - nameSpace = atOnboardingConfig.namespace!; - - AtOnboardingService onboardingService = - AtOnboardingServiceImpl(deviceAtsign, atOnboardingConfig); - - await onboardingService.authenticate(); - - atClient = AtClientManager.getInstance().atClient; - - // check if sshnp atSign exists - while(!(await atSignIsActivated(atClient, managerAtsign))) { - await Future.delayed(Duration(seconds: 5)); - logger.warning('Waiting for $managerAtsign to be activated...'); - } - - NotificationService notificationService = atClient.notificationService; - - if (results['username']) { - var metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..ttr=-1 - ..namespaceAware = true; - - var atKey = AtKey() - ..key = "username.$device" - ..sharedBy = deviceAtsign - ..sharedWith = managerAtsign - ..namespace = nameSpace - ..metadata = metaData; - - //await atClient.notificationService.(atKey, username); - try { - await notificationService - .notify(NotificationParams.forUpdate(atKey, value: username), - onSuccess: (notification) { - logger.info('SUCCESS:$notification $username'); - }, onError: (notification) { - logger.info('ERROR:$notification $username'); - }); - } catch (e) { - stderr.writeln(e.toString()); - } - } - - // Keep an eye on connectivity and report failures if we see them - ConnectivityListener().subscribe().listen((isConnected) { - if (isConnected) { - logger.warning('connection available'); - } else { - logger.warning('connection lost'); - } - }); - - - String privateKey = ""; - String sshPublicKey = ""; - notificationService - .subscribe(regex: '$device.$nameSpace@', shouldDecrypt: true) - .listen(((notification) async { - String notificationKey = notification.key - .replaceAll('${notification.to}:', '') - .replaceAll('.$device.$nameSpace${notification.from}', '') - // convert to lower case as the latest AtClient converts notification - // keys to lower case when received - .toLowerCase(); - - if (notificationKey == 'privatekey') { - logger.info( - 'Private Key received from ${notification.from} notification id : ${notification.id}'); - privateKey = notification.value!; - } - - if (notificationKey == 'sshpublickey') { - try { - var sshHomeDirectory = "$homeDirectory/.ssh/"; - if (Platform.isWindows) { - sshHomeDirectory = '$homeDirectory\\.ssh\\'; - } - logger.info( - 'ssh Public Key received from ${notification.from} notification id : ${notification.id}'); - sshPublicKey = notification.value!; - - // Check to see if the ssh public key looks like one! - if (!sshPublicKey.startsWith('ssh-')) { - throw ('$sshPublicKey does not look like a public key'); - } - - // Check to see if the ssh Publickey is already in the file if not append to the ~/.ssh/authorized_keys file - var authKeys = File('${sshHomeDirectory}authorized_keys'); - - var authKeysContent = await authKeys.readAsString(); - - if (!authKeysContent.contains(sshPublicKey)) { - authKeys.writeAsStringSync("\n$sshPublicKey", mode: FileMode.append); - } - } catch (e) { - logger.severe( - 'Error writing to $username .ssh/authorized_keys file : $e'); - } - } - - if (notificationKey == 'sshd') { - logger.info( - 'ssh callback request received from ${notification.from} notification id : ${notification.id}'); - sshCallback(notification, privateKey, logger, managerAtsign, deviceAtsign, - nameSpace, device); - } - }), - onError: (e) => logger.severe('Notification Failed:$e'), - onDone: () => logger.info('Notification listener stopped')); -} - -void sshCallback( - AtNotification notification, - String privateKey, - AtSignLogger logger, - String managerAtsign, - String deviceAtsign, - String nameSpace, - String device) async { - // sessionId is local if we do not have a 2.0 client - var uuid = Uuid(); - String sessionId = uuid.v4(); - - var sshString = notification.value!; - // Get atPlatform notifications ready - var metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..namespaceAware = true - ..ttr = -1 - ..ttl = 10000; - - var atKey = AtKey() - ..key = '$sessionId.$device' - ..sharedBy = deviceAtsign - ..sharedWith = managerAtsign - ..namespace = nameSpace - ..metadata = metaData; - - var atClient = AtClientManager.getInstance().atClient; - NotificationService notificationService = atClient.notificationService; - - if (notification.from == managerAtsign) { - // Local port, port of sshd , username , hostname - List sshList = sshString.split(' '); - var localPort = sshList[0]; - var port = sshList[1]; - var username = sshList[2]; - var hostname = sshList[3]; - // Assure backward compatibility with 1.x clients - if (sshList.length == 5) { - sessionId = sshList[4]; - atKey = AtKey() - ..key = '$sessionId.$device' - ..sharedBy = deviceAtsign - ..sharedWith = managerAtsign - ..namespace = nameSpace - ..metadata = metaData; - } - logger.info( - 'ssh session started for $username to $hostname on port $port using localhost:$localPort on $hostname '); - logger.shout( - 'ssh session started from: ${notification.from} session: $sessionId'); - - // var result = await Process.run('ssh', sshList); - - try { - final socket = await SSHSocket.connect(hostname, int.parse(port)); - - final client = SSHClient( - socket, - username: username, - identities: [ - // A single private key file may contain multiple keys. - ...SSHKeyPair.fromPem(privateKey) - ], - ); - // connect back to ssh server/port - await client.authenticated; - // Do the port forwarding - final forward = await client.forwardRemote(port: int.parse(localPort)); - - if (forward == null) { - logger.warning('Failed to forward remote port $localPort'); - try { - // Say this session is NOT connected to client - await notificationService.notify( - NotificationParams.forUpdate(atKey, - value: - 'Failed to forward remote port $localPort, (use --local-port to specify unused port)'), - onSuccess: (notification) { - logger.info('SUCCESS:$notification for: $sessionId'); - }, onError: (notification) { - logger.info('ERROR:$notification'); - }); - } catch (e) { - stderr.writeln(e.toString()); - } - return; - } - - /// Send a notification to tell sshnp connection is made - /// - - try { - // Say this session is connected to client - logger.info(' sshnpd connected notification sent to:from "$atKey'); - await notificationService - .notify(NotificationParams.forUpdate(atKey, value: "connected"), - onSuccess: (notification) { - logger.info('SUCCESS:$notification for: $sessionId'); - }, onError: (notification) { - logger.info('ERROR:$notification'); - }); - } catch (e) { - stderr.writeln(e.toString()); - } - - /// - - int counter = 0; - bool stop = false; - // Set up time to check to see if all connections are down - Timer.periodic(Duration(seconds: 15), (timer) async { - if (counter == 0) { - client.close(); - await client.done; - stop = true; - timer.cancel(); - logger.shout( - 'ssh session complete for: ${notification.from} session: $sessionId'); - } - }); - // Answer ssh requests until none are left open - await for (final connection in forward.connections) { - counter++; - final socket = await Socket.connect('localhost', 22); - - // ignore: unawaited_futures - connection.stream.cast>().pipe(socket).whenComplete(() async { - counter--; - }); - // ignore: unawaited_futures - socket.pipe(connection.sink); - if (stop) break; - } - } catch (e) { - // need to make sure things close - logger.severe('SSH Client failure : $e'); - try { - // Say this session is connected to client - await notificationService.notify( - NotificationParams.forUpdate(atKey, - value: 'Remote SSH Client failure : $e'), - onSuccess: (notification) { - logger.info('SUCCESS:$notification for: $sessionId'); - }, onError: (notification) { - logger.info('ERROR:$notification'); - }); - } catch (e) { - stderr.writeln(e.toString()); - } - } - } else { - logger.shout( - 'ssh session attempted from: ${notification.from} session: $sessionId and ignored'); - } -} +} \ No newline at end of file diff --git a/lib/sshnp.dart b/lib/sshnp.dart index 5b75ff402..ee7654237 100644 --- a/lib/sshnp.dart +++ b/lib/sshnp.dart @@ -541,6 +541,7 @@ class SSHNP { String sessionId = Uuid().v4(); + AtSignLogger.root_level = 'SHOUT'; if (p.verbose) { AtSignLogger.root_level = 'INFO'; } diff --git a/lib/sshnpd.dart b/lib/sshnpd.dart new file mode 100644 index 000000000..43a8f85b3 --- /dev/null +++ b/lib/sshnpd.dart @@ -0,0 +1,497 @@ +// dart packages +import 'dart:async'; +import 'dart:io'; + +// atPlatform packages +import 'package:at_onboarding_cli/at_onboarding_cli.dart'; +import 'package:at_utils/at_logger.dart'; +import 'package:at_client/at_client.dart'; + +// external packages +import 'package:args/args.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:version/version.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:uuid/uuid.dart'; + +// local packages +import 'package:sshnoports/version.dart'; +import 'package:sshnoports/service_factories.dart'; +import 'package:sshnoports/sshnpd_utils.dart'; + +const String nameSpace = 'sshnp'; + +class SSHNPD { + + final AtSignLogger logger = AtSignLogger(' sshnpd '); + + /// The [AtClient] used to communicate with sshnpd and sshrvd + late AtClient atClient; + + // ==================================================================== + // Final instance variables, injected via constructor + // ==================================================================== + /// The user name on this host + final String username; + + /// The home directory on this host + final String homeDirectory; + + /// The device name on this host + final String device; + + String get deviceAtsign => atClient.getCurrentAtSign()!; + late final String managerAtsign; + + /// true once [init] has completed + @visibleForTesting + bool initialized = false; + + static const String commandToSend = 'sshd'; + + SSHNPD( + { + // final fields + required this.atClient, + required this.username, + required this.homeDirectory, + // volatile fields + required this.device, + required this.managerAtsign}) { + logger.hierarchicalLoggingEnabled = true; + logger.logger.level = Level.SHOUT; + } + + /// Must be run after construction, to complete initialization + /// - Ensure that initialization is only performed once. + /// - If the object has already been initialized, it throws a StateError indicating that initialization cannot be performed again. + Future init() async { + if (initialized) { + throw StateError('Cannot init() - already initialized'); + } + + initialized = true; + } + + /// Must be run after [init], to start the sshnpd service + /// - Starts connectivity listener to receive requests from sshnp + /// - Subscribes to notifications matching the pattern '$device\.$nameSpace@', with decryption enabled. + /// - Listens for notifications and handles different notification types ('privatekey', 'sshpublickey', 'sshd'). + /// - If a 'privatekey' notification is received, it extracts and stores the private key. + /// - If an 'sshpublickey' notification is received, Checks if the SSH public key is valid, Appends the SSH public key to the authorized_keys file in the user's SSH directory if it is not already present + /// - If an 'sshd' notification is received, it triggers the sshCallback function to handle the SSH callback request. + Future run() async { + if (!initialized) { + throw StateError('Cannot run() - not initialized'); + } + + NotificationService notificationService = atClient.notificationService; + + if (username != '') { + var metaData = Metadata() + ..isPublic = false + ..isEncrypted = true + ..ttr = -1 + ..namespaceAware = true; + + var atKey = AtKey() + ..key = "username.$device" + ..sharedBy = deviceAtsign + ..sharedWith = managerAtsign + ..namespace = nameSpace + ..metadata = metaData; + + try { + await notificationService + .notify(NotificationParams.forUpdate(atKey, value: username), + waitForFinalDeliveryStatus: false, + checkForFinalDeliveryStatus: false, + onSuccess: (notification) { + logger.info('SUCCESS:$notification $username'); + }, onError: (notification) { + logger.info('ERROR:$notification $username'); + }); + } catch (e) { + stderr.writeln(e.toString()); + } + } + + logger.info('Starting connectivity listener'); + // Keep an eye on connectivity and report failures if we see them + ConnectivityListener().subscribe().listen((isConnected) { + if (isConnected) { + logger.warning('connection available'); + } else { + logger.warning('connection lost'); + } + }); + + String privateKey = ""; + String sshPublicKey = ""; + logger.info('Subscribing to $device\\.$nameSpace@'); + notificationService + .subscribe(regex: '$device\\.$nameSpace@', shouldDecrypt: true) + .listen(((notification) async { + String notificationKey = notification.key + .replaceAll('${notification.to}:', '') + .replaceAll('.$device.$nameSpace${notification.from}', '') + // convert to lower case as the latest AtClient converts notification + // keys to lower case when received + .toLowerCase(); + + if (notificationKey == 'privatekey') { + logger.info( + 'Private Key received from ${notification.from} notification id : ${notification.id}'); + privateKey = notification.value!; + } + if (notificationKey == 'sshpublickey') { + try { + var sshHomeDirectory = "$homeDirectory/.ssh/"; + if (Platform.isWindows) { + sshHomeDirectory = '$homeDirectory\\.ssh\\'; + } + logger.info( + 'ssh Public Key received from ${notification.from} notification id : ${notification.id}'); + sshPublicKey = notification.value!; + + // Check to see if the ssh public key looks like one! + if (!sshPublicKey.startsWith('ssh-')) { + throw ('$sshPublicKey does not look like a public key'); + } + + // Check to see if the ssh Publickey is already in the file if not append to the ~/.ssh/authorized_keys file + var authKeys = File('${sshHomeDirectory}authorized_keys'); + + var authKeysContent = await authKeys.readAsString(); + + if (!authKeysContent.contains(sshPublicKey)) { + authKeys.writeAsStringSync("\n$sshPublicKey", + mode: FileMode.append); + } + } catch (e) { + logger.severe( + 'Error writing to $username .ssh/authorized_keys file : $e'); + } + } + + if (notificationKey == 'sshd') { + logger.info( + 'ssh callback request received from ${notification.from} notification id : ${notification.id}'); + sshCallback(notification, privateKey, logger, managerAtsign, + deviceAtsign, device); + } + }), + onError: (e) => logger.severe('Notification Failed:$e'), + onDone: () => logger.info('Notification listener stopped')); + } + + static ArgParser createArgParser() { + var parser = ArgParser(); + + // Basic arguments + parser.addOption('keyFile', + abbr: 'k', + mandatory: false, + help: 'Sending atSign\'s keyFile if not in ~/.atsign/keys/'); + parser.addOption('atsign', + abbr: 'a', mandatory: true, help: 'atSign of this device'); + parser.addOption('manager', + abbr: 'm', + mandatory: true, + help: 'Managers atSign, that this device will accept triggers from'); + parser.addOption('device', + abbr: 'd', + mandatory: false, + defaultsTo: "default", + help: + 'Send a trigger to this device, allows multiple devices share an atSign'); + + parser.addFlag('sshpublickey', + abbr: 's', + help: 'Update authorized_keys to include public key from sshnp'); + parser.addFlag('username', + abbr: 'u', + help: + 'Send username to the manager to allow sshnp to display username in command line'); + parser.addFlag('verbose', abbr: 'v', help: 'More logging'); + + return parser; + } + + static SSHNPDParams parseSSHNPDParams(List args) { + var p = SSHNPDParams(); + + // 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)!; + + // Do we have a device ? + p.device = r['device']; + + // Do we have an ASCII ? + if (checkNonAscii(p.device)) { + throw ('\nDevice name can only contain alphanumeric characters with a max length of 15'); + } + + // Find atSign key file + if (r['keyFile'] != null) { + p.atKeysFilePath = r['keyFile']; + } else { + p.deviceAtsign = r['atsign']; + p.managerAtsign = r['manager']; + p.atKeysFilePath = + getDefaultAtKeysFilePath(p.homeDirectory, p.deviceAtsign); + } + + p.verbose = r['verbose']; + + return p; + } + + static Future fromCommandLineArgs(List args) async { + try { + var p = parseSSHNPDParams(args); + + // Check atKeyFile selected exists + if (!File(p.atKeysFilePath).existsSync()) { + throw ('\n Unable to find .atKeys file : ${p.atKeysFilePath}'); + } + + String sessionId = Uuid().v4(); + + AtSignLogger.root_level = 'SHOUT'; + if (p.verbose) { + AtSignLogger.root_level = 'INFO'; + } + + AtClient atClient = await createAtClient( + homeDirectory: p.homeDirectory, + deviceAtsign: p.deviceAtsign, + sessionId: sessionId, + atKeysFilePath: p.atKeysFilePath); + + var sshnpd = SSHNPD( + atClient: atClient, + username: p.username, + homeDirectory: p.homeDirectory, + device: p.device, + managerAtsign: p.managerAtsign); + if (p.verbose) { + sshnpd.logger.logger.level = Level.INFO; + } + + return sshnpd; + } catch (e) { + version(); + stdout.writeln(createArgParser().usage); + stderr.writeln(e); + exit(1); + } + } + + static Future createAtClient( + {required String homeDirectory, + required String deviceAtsign, + 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 = '$homeDirectory/.sshnp/$deviceAtsign/storage' + .replaceAll('/', Platform.pathSeparator) + ..namespace = 'sshnp' + ..downloadPath = + '$homeDirectory/.sshnp/files'.replaceAll('/', Platform.pathSeparator) + ..isLocalStoreRequired = true + ..commitLogPath = '$homeDirectory/.sshnp/$deviceAtsign/storage/commitLog' + .replaceAll('/', Platform.pathSeparator) + ..fetchOfflineNotifications = false + ..atKeysFilePath = atKeysFilePath + ..atProtocolEmitted = Version(2, 0, 0); + + AtOnboardingService onboardingService = AtOnboardingServiceImpl( + deviceAtsign, atOnboardingConfig, + atServiceFactory: ServiceFactoryWithNoOpSyncService()); + + await onboardingService.authenticate(); + + return AtClientManager.getInstance().atClient; + } + + void sshCallback( + AtNotification notification, + String privateKey, + AtSignLogger logger, + String managerAtsign, + String deviceAtsign, + String device) async { + // sessionId is local if we do not have a 2.0 client + var uuid = Uuid(); + String sessionId = uuid.v4(); + + var sshString = notification.value!; + // Get atPlatform notifications ready + var metaData = Metadata() + ..isPublic = false + ..isEncrypted = true + ..namespaceAware = true + ..ttr = -1 + ..ttl = 10000; + + var atKey = AtKey() + ..key = '$sessionId.$device' + ..sharedBy = deviceAtsign + ..sharedWith = managerAtsign + ..namespace = nameSpace + ..metadata = metaData; + + var atClient = AtClientManager.getInstance().atClient; + NotificationService notificationService = atClient.notificationService; + + if (notification.from == managerAtsign) { + // Local port, port of sshd , username , hostname + List sshList = sshString.split(' '); + var localPort = sshList[0]; + var port = sshList[1]; + var username = sshList[2]; + var hostname = sshList[3]; + // Assure backward compatibility with 1.x clients + if (sshList.length == 5) { + sessionId = sshList[4]; + atKey = AtKey() + ..key = '$sessionId.$device' + ..sharedBy = deviceAtsign + ..sharedWith = managerAtsign + ..namespace = nameSpace + ..metadata = metaData; + } + logger.info( + 'ssh session started for $username to $hostname on port $port using localhost:$localPort on $hostname '); + logger.shout( + 'ssh session started from: ${notification.from} session: $sessionId'); + + // var result = await Process.run('ssh', sshList); + + try { + final socket = await SSHSocket.connect(hostname, int.parse(port)); + + final client = SSHClient( + socket, + username: username, + identities: [ + // A single private key file may contain multiple keys. + ...SSHKeyPair.fromPem(privateKey) + ], + ); + // connect back to ssh server/port + await client.authenticated; + // Do the port forwarding + final forward = await client.forwardRemote(port: int.parse(localPort)); + + if (forward == null) { + logger.warning('Failed to forward remote port $localPort'); + try { + // Say this session is NOT connected to client + await notificationService.notify( + NotificationParams.forUpdate(atKey, + value: + 'Failed to forward remote port $localPort, (use --local-port to specify unused port)'), + onSuccess: (notification) { + logger.info('SUCCESS:$notification for: $sessionId'); + }, onError: (notification) { + logger.info('ERROR:$notification'); + }); + } catch (e) { + stderr.writeln(e.toString()); + } + return; + } + + /// Send a notification to tell sshnp connection is made + /// + + try { + // Say this session is connected to client + logger.info(' sshnpd connected notification sent to:from "$atKey'); + await notificationService + .notify(NotificationParams.forUpdate(atKey, value: "connected"), + onSuccess: (notification) { + logger.info('SUCCESS:$notification for: $sessionId'); + }, onError: (notification) { + logger.info('ERROR:$notification'); + }); + } catch (e) { + stderr.writeln(e.toString()); + } + + /// + + int counter = 0; + bool stop = false; + // Set up time to check to see if all connections are down + Timer.periodic(Duration(seconds: 15), (timer) async { + if (counter == 0) { + client.close(); + await client.done; + stop = true; + timer.cancel(); + logger.shout( + 'ssh session complete for: ${notification.from} session: $sessionId'); + } + }); + // Answer ssh requests until none are left open + await for (final connection in forward.connections) { + counter++; + final socket = await Socket.connect('localhost', 22); + + // ignore: unawaited_futures + connection.stream + .cast>() + .pipe(socket) + .whenComplete(() async { + counter--; + }); + // ignore: unawaited_futures + socket.pipe(connection.sink); + if (stop) break; + } + } catch (e) { + // need to make sure things close + logger.severe('SSH Client failure : $e'); + try { + // Say this session is connected to client + await notificationService.notify( + NotificationParams.forUpdate(atKey, + value: 'Remote SSH Client failure : $e'), + onSuccess: (notification) { + logger.info('SUCCESS:$notification for: $sessionId'); + }, onError: (notification) { + logger.info('ERROR:$notification'); + }); + } catch (e) { + stderr.writeln(e.toString()); + } + } + } else { + logger.shout( + 'ssh session attempted from: ${notification.from} session: $sessionId and ignored'); + } + } +} + +class SSHNPDParams { + late final String device; + late final String username; + late final String homeDirectory; + late final String managerAtsign; + late final String atKeysFilePath; + late final String sendSshPublicKey; + late final String deviceAtsign; + late final bool verbose; +} diff --git a/lib/sshnpd_utils.dart b/lib/sshnpd_utils.dart new file mode 100644 index 000000000..b2460a3d1 --- /dev/null +++ b/lib/sshnpd_utils.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +String? getUserName({bool throwIfNull = false}) { + + // Do we have a username ? + 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; +} + +/// 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; +} + +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); +} + +bool checkNonAscii(String test) { + var extra = test.replaceAll(RegExp(r'[a-zA-Z0-9_]*'), ''); + if ((extra != '') || (test.length > 15)) { + return true; + } else { + return false; + } +} \ No newline at end of file diff --git a/test/sshnpd_test.dart b/test/sshnpd_test.dart new file mode 100644 index 000000000..4255f0c0a --- /dev/null +++ b/test/sshnpd_test.dart @@ -0,0 +1,68 @@ +import 'package:test/test.dart'; +import 'package:args/args.dart'; +import 'package:sshnoports/sshnpd.dart'; +import 'package:sshnoports/sshnpd_utils.dart'; + +void main(){ + group('args parser test', () { + test('test mandatory args', () { + ArgParser parser = SSHNPD.createArgParser(); + + List args = []; + expect(() => parser.parse(args)['atsign'], throwsA(isA())); + + args.addAll(['-a','@bob']); + expect(parser.parse(args)['atsign'], '@bob'); + expect(() => parser.parse(args)['manager'], throwsA(isA())); + + args.addAll(['-m','@alice']); + expect(parser.parse(args)['atsign'], '@bob'); + expect(parser.parse(args)['manager'], '@alice'); + }); + + test('test parsed args with only mandatory provided', () { + List args = []; + + args.addAll(['-a', '@bob']); + args.addAll(['-m', '@alice']); + + var p = SSHNPD.parseSSHNPDParams(args); + + expect(p.deviceAtsign, '@bob'); + expect(p.managerAtsign, '@alice'); + + expect(p.device, 'default'); + expect(p.username, getUserName(throwIfNull: true)); + expect(p.homeDirectory, getHomeDirectory(throwIfNull:true)); + expect(p.verbose, false); + expect(p.atKeysFilePath, getDefaultAtKeysFilePath(p.homeDirectory, p.deviceAtsign)); + }); + + test('test parsed args with non-mandatory args provided', () { + List args = []; + + args.addAll(['-a', '@bob']); + args.addAll(['-m', '@alice']); + + args.addAll([ + '-d', 'device', + '-u', + '-v', + '-s', + '-u', + ]); + + + var p = SSHNPD.parseSSHNPDParams(args); + + expect(p.deviceAtsign, '@bob'); + expect(p.managerAtsign, '@alice'); + + expect(p.device, 'device'); + expect(p.username, getUserName(throwIfNull: true)); + expect(p.homeDirectory, getHomeDirectory(throwIfNull:true)); + expect(p.verbose, true); + expect(p.atKeysFilePath, getDefaultAtKeysFilePath(p.homeDirectory, p.deviceAtsign)); + }); + }); +} \ No newline at end of file