diff --git a/.github/composite/setup_entrypoints/action.yaml b/.github/composite/setup_entrypoints/action.yaml index 994874ab4..4ed0c0c93 100644 --- a/.github/composite/setup_entrypoints/action.yaml +++ b/.github/composite/setup_entrypoints/action.yaml @@ -21,6 +21,9 @@ inputs: devicename: description: Unique sshnp devicename required: true + legacy_daemon: + description: Legacy daemon + required: false runs: using: composite @@ -37,8 +40,7 @@ runs: entrypoint_filename="sshnp_entrypoint.sh" ;; esac - echo "entrypoint_filename: $entrypoint_filename" - ./setup-sshnp-entrypoint.sh ${{ inputs.devicename }} ${{ inputs.sshnp_atsign }} ${{ inputs.sshnpd_atsign }} ${{ inputs.sshrvd_atsign }} "$entrypoint_filename" + ./setup-sshnp-entrypoint.sh ${{ inputs.devicename }} ${{ inputs.sshnp_atsign }} ${{ inputs.sshnpd_atsign }} ${{ inputs.sshrvd_atsign }} "$entrypoint_filename" ${{ inputs.legacy_daemon || false }} - name: Setup NPD entrypoint shell: bash diff --git a/.github/composite/verify_cli_tags/action.yaml b/.github/composite/verify_cli_tags/action.yaml new file mode 100644 index 000000000..8a49793a7 --- /dev/null +++ b/.github/composite/verify_cli_tags/action.yaml @@ -0,0 +1,30 @@ +name: verify_cli_tags +description: | + Ensures that the tag of sshnoports matches the tag of the composite. + +runs: + using: composite + steps: + - name: Ensure pubspec.yaml matches version.dart + shell: bash + working-directory: ./packages/sshnoports + run: | + DART_TAG="v$(egrep -o '^const String version = "(.*)";' lib/version.dart | cut -d'"' -f2)" + PUBSPEC_TAG="v$(egrep -o '^version: (.*)$' pubspec.yaml | cut -d':' -f2 | tr -d '[:space:]')" + if [ "$PUBSPEC_TAG" != "$DART_TAG" ]; then + echo "Tag $PUBSPEC_TAG does not match version in version.dart: $DART_TAG" + exit 1 + fi + - name: Ensure version.dart matches git ref (if current git ref is a version tag) + shell: bash + if: startsWith(github.ref, 'refs/tags/v') + working-directory: ./packages/sshnoports + run: | + # check version.dart + REF=${{ github.ref }} + TAG=${REF:10} + DART_TAG="v$(egrep -o '^const String version = "(.*)";' lib/version.dart | cut -d'"' -f2)" + if [ "$TAG" != "$DART_TAG" ]; then + echo "Tag $TAG does not match version in version.dart: $DART_TAG" + exit 1 + fi diff --git a/.github/composite/verify_core_tags/action.yaml b/.github/composite/verify_core_tags/action.yaml new file mode 100644 index 000000000..eaf2b5e5d --- /dev/null +++ b/.github/composite/verify_core_tags/action.yaml @@ -0,0 +1,17 @@ +name: verify_core_tags +description: | + Ensures that the tag of noports_core matches the tag of the composite. + +runs: + using: composite + steps: + - name: Ensure pubspec.yaml matches version.dart + shell: bash + working-directory: ./packages/noports_core + run: | + DART_TAG="v$(grep -Po '^const String version = "(.*)";' lib/version.dart | cut -d'"' -f2)" + PUBSPEC_TAG="v$(egrep -o '^version: (.*)$' pubspec.yaml | cut -d':' -f2 | tr -d '[:space:]')" + if [ "$PUBSPEC_TAG" != "$DART_TAG" ]; then + echo "Tag $PUBSPEC_TAG does not match version in version.dart: $DART_TAG" + exit 1 + fi diff --git a/.github/workflows/dockerhub_sshnpd.yml b/.github/workflows/dockerhub_sshnpd.yml index 1346e5dcf..18f907847 100644 --- a/.github/workflows/dockerhub_sshnpd.yml +++ b/.github/workflows/dockerhub_sshnpd.yml @@ -23,18 +23,9 @@ jobs: - name: Checkout uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Ensure version matches tag - if: startsWith(github.ref, 'refs/tags/v') - working-directory: ./packages/sshnoports - run: | - # check version.dart - REF=${{ github.ref }} - TAG=${REF:10} - DART_TAG="v$(grep -Po '^const String version = "(.*)";' lib/version.dart | cut -d'"' -f2)" - if [ "$TAG" != "$DART_TAG" ]; then - echo "Tag $TAG does not match version in version.dart: $DART_TAG" - exit 1 - fi + # verify tags + - uses: ./.github/workflows/verify_cli_tags + - uses: ./.github/workflows/verify_core_tags - name: Set up QEMU uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 @@ -51,7 +42,7 @@ jobs: - name: Build and push uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 with: - context: ./packages/sshnoports + context: . file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true diff --git a/.github/workflows/end2end_tests.yaml b/.github/workflows/end2end_tests.yaml index aa7b4afd0..d475986f9 100644 --- a/.github/workflows/end2end_tests.yaml +++ b/.github/workflows/end2end_tests.yaml @@ -244,6 +244,7 @@ jobs: sshnpd_atsign: ${{ env.SSHNPD_ATSIGN }} sshrvd_atsign: ${{ env[env.PROD_RVD_ATSIGN] }} devicename: ${{ env.DEVICENAME }} + legacy_daemon: ${{ matrix.np == 'local' && matrix.npd == 'installer' }} - name: Ensure entrypoints exist working-directory: tests/end2end_tests/contexts @@ -368,12 +369,14 @@ jobs: matrix: include: - np: local - npd: latest - - np: latest + npd: v3.4.2 + legacy_daemon: true + - np: v3.4.2 npd: local - np: local npd: v3.3.0 + legacy_daemon: true - np: v3.3.0 npd: local @@ -415,6 +418,7 @@ jobs: sshnpd_atsign: ${{ env.SSHNPD_ATSIGN }} sshrvd_atsign: ${{ env[env.PROD_RVD_ATSIGN] }} devicename: ${{ env.DEVICENAME }} + legacy_daemon: ${{ matrix.legacy_daemon || false }} - name: Ensure entrypoints exist working-directory: tests/end2end_tests/contexts @@ -610,4 +614,4 @@ jobs: continue-on-error: true # failing this step does not fail the entire job working-directory: tests/end2end_tests/tests run: | - docker compose down \ No newline at end of file + docker compose down diff --git a/.github/workflows/multibuild.yaml b/.github/workflows/multibuild.yaml index 441a392f5..77d034497 100644 --- a/.github/workflows/multibuild.yaml +++ b/.github/workflows/multibuild.yaml @@ -7,7 +7,14 @@ permissions: # added using https://github.com/step-security/secure-repo contents: read jobs: + verify_tags: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - uses: ./.github/composite/verify_cli_tags + - uses: ./.github/composite/verify_core_tags x64_build: + needs: verify_tags runs-on: ${{ matrix.os }} defaults: run: @@ -23,20 +30,6 @@ jobs: steps: - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - - name: Ensure version matches tag - if: startsWith(github.ref, 'refs/tags/v') - working-directory: ./packages/sshnoports - run: | - # check version.dart - REF=${{ github.ref }} - TAG=${REF:10} - DART_TAG="v$(egrep -o '^const String version = "(.*)";' lib/version.dart | cut -d'"' -f2)" - if [ "$TAG" != "$DART_TAG" ]; then - echo "Tag $TAG does not match version in version.dart: $DART_TAG" - exit 1 - fi - - uses: dart-lang/setup-dart@8a4b97ea2017cc079571daec46542f76189836b1 # v1.5.1 - run: mkdir sshnp - run: mkdir tarball @@ -56,6 +49,7 @@ jobs: if-no-files-found: error other_build: + needs: verify_tags runs-on: ubuntu-latest defaults: run: @@ -72,20 +66,6 @@ jobs: output-name: sshnp-linux-riscv64 steps: - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - - name: Ensure version matches tag - if: startsWith(github.ref, 'refs/tags/v') - working-directory: ./packages/sshnoports - run: | - # check version.dart - REF=${{ github.ref }} - TAG=${REF:10} - DART_TAG="v$(grep -Po '^const String version = "(.*)";' lib/version.dart | cut -d'"' -f2)" - if [ "$TAG" != "$DART_TAG" ]; then - echo "Tag $TAG does not match version in version.dart: $DART_TAG" - exit 1 - fi - - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - run: | diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml new file mode 100644 index 000000000..429b76add --- /dev/null +++ b/.github/workflows/unit_tests.yaml @@ -0,0 +1,26 @@ +name: unit_tests + +permissions: + contents: read + +on: + workflow_dispatch: + push: + branches: + - trunk + + pull_request: + branches: + - trunk + +jobs: + cli_tags: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - uses: ./.github/composite/verify_cli_tags + core_tags: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - uses: ./.github/composite/verify_core_tags diff --git a/.github/workflows/update_python_requirements.yaml b/.github/workflows/update_python_requirements.yaml index c8066f061..a5107997b 100644 --- a/.github/workflows/update_python_requirements.yaml +++ b/.github/workflows/update_python_requirements.yaml @@ -7,7 +7,7 @@ on: permissions: pull-requests: write - + jobs: bump_requirements: runs-on: ubuntu-latest @@ -32,7 +32,7 @@ jobs: uses: abatilo/actions-poetry@192395c0d10c082a7c62294ab5d9a9de40e48974 # v2.3.0 with: poetry-version: '1.6.1' - + - name: Bump Python dependencies if: ${{ github.actor == 'dependabot[bot]' }} run: | diff --git a/.gitignore b/.gitignore index 8e3ba5e87..b08064d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,8 @@ compose-dev.yaml *.hash *.atKeys -# Ignore pubspec.lock -pubspec.lock +# Ignore root pubspec.lock only +/pubspec.lock # Conventional directory for build output. build/ diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 000000000..34c62e31c --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +pubspec.lock \ No newline at end of file diff --git a/packages/noports_core/.gitignore b/packages/noports_core/.gitignore new file mode 100644 index 000000000..34c62e31c --- /dev/null +++ b/packages/noports_core/.gitignore @@ -0,0 +1 @@ +pubspec.lock \ No newline at end of file diff --git a/packages/noports_core/CHANGELOG.md b/packages/noports_core/CHANGELOG.md index 067b89500..58b982233 100644 --- a/packages/noports_core/CHANGELOG.md +++ b/packages/noports_core/CHANGELOG.md @@ -1,3 +1,3 @@ -# 4.0.0-dev.1 +# 4.0.0 -- Initial release based of the 4.0.0 pre-release code of sshnoports \ No newline at end of file +- Initial release based off of the 4.0.0 pre-release code of sshnoports \ No newline at end of file diff --git a/packages/noports_core/README.md b/packages/noports_core/README.md index b85e77cff..04375e9d4 100644 --- a/packages/noports_core/README.md +++ b/packages/noports_core/README.md @@ -5,9 +5,17 @@ # No Ports Core -Abstractions of the original sshnoports core code used for building No Ports -enabled applications. Currently in development, more documentation to come with -the official release. +No Ports Core is the underlying library used to enable sshnoports and the rest of the No Ports suite. + +## Examples + +### CLI Example + +See the [sshnoports](https://github.com/atsign-foundation/sshnoports/tree/trunk/packages/sshnoports) project. + +### Flutter Example + +See the [sshnp_gui](https://github.com/atsign-foundation/sshnoports/tree/trunk/packages/sshnp_gui) project. ## Maintainers diff --git a/packages/noports_core/lib/common/defaults.dart b/packages/noports_core/lib/common/defaults.dart deleted file mode 100644 index 10b371997..000000000 --- a/packages/noports_core/lib/common/defaults.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:noports_core/sshrv/sshrv.dart'; - -const defaultVerbose = false; -const defaultRsa = false; -const defaultRootDomain = 'root.atsign.org'; -const defaultSshrvGenerator = SSHRV.localBinary; -const defaultLocalSshdPort = 22; -const defaultRemoteSshdPort = 22; - -/// value in seconds after which idle ssh tunnels will be closed -const defaultIdleTimeout = 15; diff --git a/packages/noports_core/lib/common/supported_ssh_clients.dart b/packages/noports_core/lib/common/supported_ssh_clients.dart deleted file mode 100644 index 71e4e6d43..000000000 --- a/packages/noports_core/lib/common/supported_ssh_clients.dart +++ /dev/null @@ -1,7 +0,0 @@ -enum SupportedSshClient { - hostSsh(cliArg: '/usr/bin/ssh'), - pureDart(cliArg: 'pure-dart'); - - final String cliArg; - const SupportedSshClient({required this.cliArg}); -} diff --git a/packages/noports_core/lib/common/sync_listener.dart b/packages/noports_core/lib/common/sync_listener.dart deleted file mode 100644 index b158d0c72..000000000 --- a/packages/noports_core/lib/common/sync_listener.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:at_client/at_client.dart'; - -class MySyncProgressListener extends SyncProgressListener { - bool syncComplete = false; - String syncResult = 'syncing'; - - @override - void onSyncProgressEvent(SyncProgress syncProgress) { - if (syncProgress.syncStatus == SyncStatus.failure || - syncProgress.syncStatus == SyncStatus.success) { - syncComplete = true; - } - if (syncProgress.syncStatus == SyncStatus.failure) { - syncResult = 'Failed'; - } - if (syncProgress.syncStatus == SyncStatus.success) { - syncResult = 'Succeeded'; - } - - return; - } -} diff --git a/packages/noports_core/lib/src/common/default_args.dart b/packages/noports_core/lib/src/common/default_args.dart new file mode 100644 index 000000000..3457e9b26 --- /dev/null +++ b/packages/noports_core/lib/src/common/default_args.dart @@ -0,0 +1,30 @@ +import 'package:noports_core/src/common/supported_ssh_clients.dart'; +import 'package:noports_core/sshrv.dart'; + +class DefaultArgs { + const DefaultArgs(); + + static const namespace = 'sshnp'; + + static const verbose = false; + static const rsa = false; + static const rootDomain = 'root.atsign.org'; + static const sshrvGenerator = SSHRV.exec; + static const localSshdPort = 22; + static const remoteSshdPort = 22; + + /// value in seconds after which idle ssh tunnels will be closed + static const idleTimeout = 15; + static const help = false; +} + +class DefaultSSHNPArgs { + static const device = 'default'; + static const port = 22; + static const localPort = 0; + static const sendSshPublicKey = ''; + static const localSshOptions = []; + static const legacyDaemon = false; + static const listDevices = false; + static const sshClient = SupportedSshClient.exec; +} diff --git a/packages/noports_core/lib/src/common/supported_ssh_clients.dart b/packages/noports_core/lib/src/common/supported_ssh_clients.dart new file mode 100644 index 000000000..317114603 --- /dev/null +++ b/packages/noports_core/lib/src/common/supported_ssh_clients.dart @@ -0,0 +1,18 @@ +enum SupportedSshClient { + exec(cliArg: '/usr/bin/ssh'), + dart(cliArg: 'pure-dart'); + + final String cliArg; + const SupportedSshClient({required this.cliArg}); + + factory SupportedSshClient.fromCliArg(String cliArg) { + switch (cliArg) { + case '/usr/bin/ssh': + return SupportedSshClient.exec; + case 'pure-dart': + return SupportedSshClient.dart; + default: + throw ArgumentError('Unsupported SSH client: $cliArg'); + } + } +} diff --git a/packages/noports_core/lib/common/utils.dart b/packages/noports_core/lib/src/common/utils.dart similarity index 98% rename from packages/noports_core/lib/common/utils.dart rename to packages/noports_core/lib/src/common/utils.dart index ac8c36a76..366904ece 100644 --- a/packages/noports_core/lib/common/utils.dart +++ b/packages/noports_core/lib/src/common/utils.dart @@ -49,10 +49,10 @@ Future fileExists(String file) async { return f; } -const String asciiMatcher = r'[a-zA-Z0-9_]{0,15}'; +const String sshnpDeviceNameRegex = r'[a-zA-Z0-9_]{0,15}'; bool checkNonAscii(String test) { - return RegExp(asciiMatcher).allMatches(test).first.group(0) != test; + return RegExp(sshnpDeviceNameRegex).allMatches(test).first.group(0) != test; } String getDefaultAtKeysFilePath(String homeDirectory, String? atSign) { diff --git a/packages/noports_core/lib/src/sshnp/sshnp.dart b/packages/noports_core/lib/src/sshnp/sshnp.dart new file mode 100644 index 000000000..fd17ba7c1 --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:at_client/at_client.dart' hide StringBuffer; +import 'package:noports_core/src/common/supported_ssh_clients.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_forward_dart_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_forward_exec_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_legacy_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_reverse_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_params/sshnp_params.dart'; +import 'package:noports_core/src/sshrv/sshrv.dart'; +import 'package:noports_core/src/sshnp/sshnp_result.dart'; + +typedef AtClientGenerator = FutureOr Function( + SSHNPParams params, String namespace); + +typedef UsageCallback = void Function(Object error, StackTrace stackTrace); + +abstract class SSHNP { + static Future fromParams( + SSHNPParams params, { + AtClient? atClient, + AtClientGenerator? atClientGenerator, + SSHRVGenerator? sshrvGenerator, + bool? shouldInitialize, + }) async { + atClient ??= await atClientGenerator?.call( + params, SSHNPImpl.getNamespace(params.device)); + + if (atClient == null) { + throw ArgumentError( + 'atClient must be provided or atClientGenerator must be provided'); + } + + if (params.legacyDaemon) { + return SSHNP.legacy( + atClient: atClient, + params: params, + sshrvGenerator: sshrvGenerator, + shouldInitialize: shouldInitialize, + ); + } + + if (!params.host.startsWith('@')) { + return SSHNP.reverse( + atClient: atClient, + params: params, + sshrvGenerator: sshrvGenerator, + shouldInitialize: shouldInitialize, + ); + } + + switch (SupportedSshClient.fromCliArg(params.sshClient)) { + case SupportedSshClient.exec: + return SSHNP.forwardExec( + atClient: atClient, + params: params, + shouldInitialize: shouldInitialize, + ); + case SupportedSshClient.dart: + return SSHNP.forwardDart( + atClient: atClient, + params: params, + shouldInitialize: shouldInitialize, + ); + default: + throw ArgumentError('Unsupported ssh client: ${params.sshClient}'); + } + } + + /// Creates an SSHNP instance that is configured to communicate with legacy >= 3.0.0 <4.0.0 daemons + factory SSHNP.legacy({ + required AtClient atClient, + required SSHNPParams params, + SSHRVGenerator? sshrvGenerator, + bool? shouldInitialize, + }) => + SSHNPLegacyImpl( + atClient: atClient, + params: params, + sshrvGenerator: sshrvGenerator, + shouldInitialize: shouldInitialize, + ); + + /// Creates an SSHNP instance that is configured to use reverse ssh tunneling + factory SSHNP.reverse({ + required AtClient atClient, + required SSHNPParams params, + SSHRVGenerator? sshrvGenerator, + bool? shouldInitialize, + }) => + SSHNPReverseImpl( + atClient: atClient, + params: params, + sshrvGenerator: sshrvGenerator, + shouldInitialize: shouldInitialize, + ); + + /// Creates an SSHNP instance that is configured to use direct ssh tunneling by executing the ssh command + factory SSHNP.forwardExec({ + required AtClient atClient, + required SSHNPParams params, + bool? shouldInitialize, + }) => + SSHNPForwardExecImpl( + atClient: atClient, + params: params, + shouldInitialize: shouldInitialize, + ); + + /// Creates an SSHNP instance that is configured to use direct ssh tunneling using a pure-dart SSHClient + factory SSHNP.forwardDart({ + required AtClient atClient, + required SSHNPParams params, + bool? shouldInitialize, + }) => + SSHNPForwardDartImpl( + atClient: atClient, + params: params, + shouldInitialize: shouldInitialize, + ); + + /// The atClient to use for communicating with the atsign's secondary server + AtClient get atClient; + + /// The parameters used to configure this SSHNP instance + SSHNPParams get params; + + /// Completes when the SSHNP instance is no longer doing anything + /// e.g. controlling a direct ssh tunnel using the pure-dart SSHClient + Future get done; + + /// Completes after asynchronous initialization has completed + Future get initialized; + + /// 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] + FutureOr init(); + + /// 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 + FutureOr run(); + + /// Send a ping out to all sshnpd and listen for heartbeats + /// Returns two Iterable and a Map: + /// - Iterable of atSigns of sshnpd that responded + /// - Iterable of atSigns of sshnpd that did not respond + /// - Map where the keys are all atSigns included in the maps, and the values being their device info + FutureOr<(Iterable, Iterable, Map)> + listDevices(); + + /// - Dispose of any resources used by this SSHNP instance + /// - Clean up temporary files + FutureOr cleanUp(); +} diff --git a/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_dart_impl.dart b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_dart_impl.dart new file mode 100644 index 000000000..1a92d5711 --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_dart_impl.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:at_client/at_client.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_forward_direction.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_impl.dart'; +import 'package:noports_core/sshnp.dart'; + +class SSHNPForwardDartImpl extends SSHNPImpl with SSHNPForwardDirection { + SSHNPForwardDartImpl({ + required AtClient atClient, + required SSHNPParams params, + bool? shouldInitialize, + }) : super( + atClient: atClient, + params: params, + shouldInitialize: shouldInitialize, + ); + + @override + Future run() async { + await startAndWaitForInit(); + + var error = await requestSocketTunnelFromDaemon(); + if (error != null) { + return error; + } + + logger.info( + 'Starting direct ssh session for ${params.username} to $host on port $sshrvdPort with forwardLocal of $localPort'); + try { + late final SSHClient client; + + late final SSHSocket socket; + try { + socket = await SSHSocket.connect(host, sshrvdPort); + } catch (e, s) { + var error = SSHNPError( + 'Failed to open socket to $host:$port : $e', + error: e, + stackTrace: s, + ); + doneCompleter.completeError(error); + return error; + } + + try { + client = SSHClient( + socket, + username: remoteUsername, + identities: [ + // A single private key file may contain multiple keys. + ...SSHKeyPair.fromPem(ephemeralPrivateKey) + ], + keepAliveInterval: Duration(seconds: 15), + ); + } catch (e, s) { + var error = SSHNPError( + 'Failed to create SSHClient for ${params.username}@$host:$port : $e', + error: e, + stackTrace: s, + ); + doneCompleter.completeError(error); + return error; + } + + try { + await client.authenticated; + } catch (e, s) { + var error = SSHNPError( + 'Failed to authenticate as ${params.username}@$host:$port : $e', + error: e, + stackTrace: s, + ); + doneCompleter.completeError(error); + return error; + } + + int counter = 0; + + Future startForwarding( + {required int fLocalPort, + required String fRemoteHost, + required int fRemotePort}) async { + logger.info('Starting port forwarding' + ' from localhost:$fLocalPort on local side' + ' to $fRemoteHost:$fRemotePort on remote side'); + + /// Do the port forwarding for sshd + final serverSocket = await ServerSocket.bind('localhost', fLocalPort); + + serverSocket.listen((socket) async { + counter++; + final forward = await client.forwardLocal(fRemoteHost, fRemotePort); + unawaited( + forward.stream.cast>().pipe(socket).whenComplete( + () async { + counter--; + }, + ), + ); + unawaited(socket.pipe(forward.sink)); + }, onError: (Object error) { + counter = 0; + }, onDone: () { + counter = 0; + }); + } + + // Start local forwarding to the remote sshd + await startForwarding( + fLocalPort: localPort, + fRemoteHost: 'localhost', + fRemotePort: params.remoteSshdPort); + + if (params.addForwardsToTunnel) { + var optionsSplitBySpace = params.localSshOptions.join(' ').split(' '); + logger.info('addForwardsToTunnel is true;' + ' localSshOptions split by space is $optionsSplitBySpace'); + // parse the localSshOptions, extract all of the local port forwarding + // directives and act on all of them + var lsoIter = optionsSplitBySpace.iterator; + while (lsoIter.moveNext()) { + if (lsoIter.current == '-L') { + // we expect the args next + bool hasArgs = lsoIter.moveNext(); + if (!hasArgs) { + logger.warning('localSshOptions has -L with no args'); + continue; + } + String argString = lsoIter.current; + // We expect args like $localPort:$remoteHost:$remotePort + List args = argString.split(':'); + if (args.length != 3) { + logger.warning('localSshOptions has -L with bad args $argString'); + continue; + } + int? fLocalPort = int.tryParse(args[0]); + String fRemoteHost = args[1]; + int? fRemotePort = int.tryParse(args[2]); + if (fLocalPort == null || + fRemoteHost.isEmpty || + fRemotePort == null) { + logger.warning('localSshOptions has -L with bad args $argString'); + continue; + } + + // Start the forwarding + await startForwarding( + fLocalPort: fLocalPort, + fRemoteHost: fRemoteHost, + fRemotePort: fRemotePort, + ); + } + } + } + + /// Set up timer to check to see if all connections are down + String terminateMessage = + 'ssh session will terminate after ${params.idleTimeout} seconds' + ' if it is not being used'; + logger.info(terminateMessage); + Timer.periodic(Duration(seconds: params.idleTimeout), (timer) async { + if (counter == 0 || client.isClosed) { + timer.cancel(); + if (!client.isClosed) client.close(); + doneCompleter.complete(); + logger.shout( + '$sessionId | no active connections - ssh session complete'); + } + }); + + return SSHNPNoOpSuccess( + message: 'Connection established:\n$terminateMessage', + connectionBean: client); + } on SSHNPError catch (e, s) { + doneCompleter.completeError(e, s); + return e; + } catch (e, s) { + doneCompleter.completeError(e, s); + return SSHNPError( + 'SSH Client failure : $e', + error: e, + stackTrace: s, + ); + } + } +} diff --git a/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_direction.dart b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_direction.dart new file mode 100644 index 000000000..e55766af9 --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_direction.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:at_client/at_client.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_result.dart'; +import 'package:noports_core/utils.dart'; + +mixin SSHNPForwardDirection on SSHNPImpl { + // Direct ssh is only ever done with a sshrvd host + // So we should expect that sshrvdPort is never null + // Hence overriding the getter and setter to make it non-nullable + late int _sshrvdPort; + @override + int get sshrvdPort => _sshrvdPort; + @override + set sshrvdPort(int? port) => _sshrvdPort = port!; + + @override + Future init() async { + await super.init(); + initializedCompleter.complete(); + } + + Future requestSocketTunnelFromDaemon() async { + logger.info( + 'Requesting daemon to set up socket tunnel for direct ssh session'); +// send request to the daemon via notification + await notify( + AtKey() + ..key = 'ssh_request' + ..namespace = this.namespace + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000), + signAndWrapAndJsonEncode(atClient, { + 'direct': true, + 'sessionId': sessionId, + 'host': host, + 'port': port + }), + sessionId: sessionId); + + bool acked = await waitForDaemonResponse(); + if (!acked) { + var error = SSHNPError( + 'sshnp timed out: waiting for daemon response\nhint: make sure the device is online', + stackTrace: StackTrace.current); + doneCompleter.completeError(error); + return error; + } + + if (sshnpdAckErrors) { + var error = SSHNPError('sshnp failed: with sshnpd acknowledgement errors', + stackTrace: StackTrace.current); + doneCompleter.completeError(error); + return error; + } + + return null; + } +} diff --git a/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_exec_impl.dart b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_exec_impl.dart new file mode 100644 index 000000000..82147f7fe --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_forward_exec_impl.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:at_client/at_client.dart' hide StringBuffer; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_forward_direction.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_impl.dart'; +import 'package:noports_core/sshnp.dart'; +import 'package:path/path.dart' as path; + +class SSHNPForwardExecImpl extends SSHNPImpl with SSHNPForwardDirection { + SSHNPForwardExecImpl({ + required AtClient atClient, + required SSHNPParams params, + bool? shouldInitialize, + }) : super( + atClient: atClient, + params: params, + shouldInitialize: shouldInitialize, + ); + + @override + Future run() async { + await startAndWaitForInit(); + + var error = await requestSocketTunnelFromDaemon(); + if (error != null) { + return error; + } + + logger.info( + 'Starting direct ssh session for ${params.username} to $host on port $sshrvdPort with forwardLocal of $localPort'); + + try { + String? errorMessage; + Process? process; + + // If using exec then we can assume we're on something unix-y + // So we can write the ephemeralPrivateKey to a tmp file, + // set its permissions appropriately, and remove it after we've + // executed the command + var tmpFileName = + path.normalize('$sshHomeDirectory/tmp/ephemeral_$sessionId'); + File tmpFile = File(tmpFileName); + await tmpFile.create(recursive: true); + await tmpFile.writeAsString(ephemeralPrivateKey, + mode: FileMode.write, flush: true); + await Process.run('chmod', ['go-rwx', tmpFileName]); + + String argsString = '$remoteUsername@$host' + ' -p $sshrvdPort' + ' -i $tmpFileName' + ' -L $localPort:localhost:${params.remoteSshdPort}' + ' -o LogLevel=VERBOSE' + ' -t -t' + ' -o StrictHostKeyChecking=accept-new' + ' -o IdentitiesOnly=yes' + ' -o BatchMode=yes' + ' -o ExitOnForwardFailure=yes' + ' -f' // fork after authentication - this is important + ; + if (params.addForwardsToTunnel) { + argsString += ' ${params.localSshOptions.join(' ')}'; + } + argsString += ' sleep 15'; + + List args = argsString.split(' '); + + logger.info('$sessionId | Executing /usr/bin/ssh ${args.join(' ')}'); + + // Because of the options we are using, we can wait for this process + // to complete, because it will exit with exitCode 0 once it has connected + // successfully + late int sshExitCode; + final soutBuf = StringBuffer(); + final serrBuf = StringBuffer(); + try { + process = await Process.start('/usr/bin/ssh', args); + process.stdout.transform(Utf8Decoder()).listen((String s) { + soutBuf.write(s); + logger.info('$sessionId | sshStdOut | $s'); + }, onError: (e) {}); + process.stderr.transform(Utf8Decoder()).listen((String s) { + serrBuf.write(s); + logger.info('$sessionId | sshStdErr | $s'); + }, onError: (e) {}); + sshExitCode = await process.exitCode.timeout(Duration(seconds: 10)); + // ignore: unused_catch_clause + } on TimeoutException catch (e) { + sshExitCode = 6464; + } + + await tmpFile.delete(); + + if (sshExitCode != 0) { + if (sshExitCode == 6464) { + logger.shout( + '$sessionId | Command timed out: /usr/bin/ssh ${args.join(' ')}'); + errorMessage = 'Failed to establish connection - timed out'; + } else { + logger.shout('$sessionId | Exit code $sshExitCode from' + ' /usr/bin/ssh ${args.join(' ')}'); + errorMessage = + 'Failed to establish connection - exit code $sshExitCode'; + } + throw SSHNPError(errorMessage); + } + + doneCompleter.complete(); + return SSHNPCommand( + localPort: localPort, + remoteUsername: remoteUsername, + host: 'localhost', + privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), + localSshOptions: + (params.addForwardsToTunnel) ? null : params.localSshOptions, + connectionBean: process, + ); + } on SSHNPError catch (e) { + doneCompleter.completeError(e, e.stackTrace); + return e; + } catch (e, s) { + doneCompleter.completeError(e, s); + return SSHNPError( + 'SSH Client failure : $e', + error: e, + stackTrace: s, + ); + } + } +} diff --git a/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_impl.dart b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_impl.dart new file mode 100644 index 000000000..6a2ce6b10 --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_impl.dart @@ -0,0 +1,548 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:at_client/at_client.dart'; +import 'package:at_commons/at_builders.dart'; +import 'package:at_utils/at_logger.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:noports_core/sshnp.dart'; +import 'package:noports_core/sshrv.dart'; +import 'package:noports_core/sshrvd.dart'; +import 'package:noports_core/utils.dart'; +import 'package:uuid/uuid.dart'; +import 'package:path/path.dart' as path; + +// If you've never seen an abstract implementation before, here it is :P +@protected +abstract class SSHNPImpl implements SSHNP { + final AtSignLogger logger = AtSignLogger(' sshnp '); + + // ==================================================================== + // Final instance variables, injected via constructor + // ==================================================================== + + @override + final AtClient atClient; + @override + final SSHNPParams params; + + final String sessionId; + + /// Function used to generate a [SSHRV] instance ([SSHRV.localbinary] by default) + final SSHRVGenerator sshrvGenerator; + + final String sshHomeDirectory; + + // ==================================================================== + // Final instance variables, derived during initialization + // ==================================================================== + + late final String remoteUsername; + + late final String publicKeyFileName; + + // ==================================================================== + // 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. + int port; + + /// Port to which sshnpd will forwardRemote its [SSHClient]. If localPort + /// is set to '0' then + int localPort; + + /// When using sshrvd, this is fetched from sshrvd during [init] + /// This is only set when using sshrvd + /// (i.e. after [getHostAndPortFromSshrvd] has been called) + int? sshrvdPort; + + // ==================================================================== + // Status indicators (Available in the public API) + // ==================================================================== + + @protected + final Completer doneCompleter = Completer(); + @override + Future get done => doneCompleter.future; + + bool _initializeStarted = false; + @protected + bool get initializeStarted => _initializeStarted; + @protected + final Completer initializedCompleter = Completer(); + @override + Future get initialized => initializedCompleter.future; + + // ==================================================================== + // Internal state variables + // ==================================================================== + + /// 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 + @protected + bool sshnpdAckErrors = false; + + /// true once we have received a response from sshrvd + @visibleForTesting + bool sshrvdAck = false; + + @protected + late String ephemeralPrivateKey; + + // ==================================================================== + // Getters for derived values + // ==================================================================== + + String get clientAtSign => atClient.getCurrentAtSign()!; + String get sshnpdAtSign => params.sshnpdAtSign; + + static String getNamespace(String device) => '$device.sshnp'; + String get namespace => getNamespace(params.device); + + // ==================================================================== + // Constructor and Initialization + // ==================================================================== + + SSHNPImpl({ + required this.atClient, + required this.params, + SSHRVGenerator? sshrvGenerator, + bool? shouldInitialize = true, + }) : sessionId = Uuid().v4(), + host = params.host, + port = params.port, + localPort = params.localPort, + sshrvGenerator = sshrvGenerator ?? DefaultArgs.sshrvGenerator, + sshHomeDirectory = getDefaultSshDirectory(params.homeDirectory) { + /// Set the logger level to shout + logger.hierarchicalLoggingEnabled = true; + logger.logger.level = Level.SHOUT; + + if (params.verbose) { + logger.logger.level = Level.INFO; + } + + /// Set the namespace to the device's namespace + AtClientPreference preference = + atClient.getPreferences() ?? AtClientPreference(); + preference.namespace = '${params.device}.sshnp'; + atClient.setPreferences(preference); + + // Set the file name for the public key based on the value of sendSshPublicKey + // previously, the default value for sendSshPublicKey was 'false' instead of '' + // immediately set it to '' to avoid the program from attempting to + // search for a public key file called 'false' + if (params.sendSshPublicKey == 'false' || params.sendSshPublicKey.isEmpty) { + logger.warning('No ssh public key file will be sent to sshnpd'); + publicKeyFileName = ''; + } else if (path.normalize(params.sendSshPublicKey).contains('/') || + path.normalize(params.sendSshPublicKey).contains(r'\')) { + publicKeyFileName = + path.normalize(path.absolute(params.sendSshPublicKey)); + logger.info( + 'Using absolute path for ssh public key file: $publicKeyFileName'); + } else { + publicKeyFileName = + path.normalize('$sshHomeDirectory/${params.sendSshPublicKey}'); + logger.info( + 'Using default .ssh path for ssh public key file: $publicKeyFileName'); + } + + /// Also call init + if (shouldInitialize ?? true) init(); + } + + @override + Future init() async { + logger.info('Initializing SSHNPImpl'); + if (_initializeStarted) { + logger.warning('Cancelling initialization: Already started'); + return; + } else { + _initializeStarted = true; + } + + try { + if (!(await atSignIsActivated(atClient, sshnpdAtSign))) { + logger.severe('Device address $sshnpdAtSign is not activated.'); + throw ('Device address $sshnpdAtSign is not activated.'); + } + } catch (e, s) { + throw SSHNPError(e, stackTrace: s); + } + + // Start listening for response notifications from sshnpd + logger.info('Subscribing to notifications on $sessionId.$namespace@'); + atClient.notificationService + .subscribe(regex: '$sessionId.$namespace@', shouldDecrypt: true) + .listen(handleSshnpdResponses); + + // Check that the public key file exists + if (publicKeyFileName.isNotEmpty && !File(publicKeyFileName).existsSync()) { + logger.info('Unable to find ssh public key file'); + throw ('Unable to find ssh public key file : $publicKeyFileName'); + } + + // Check that the private key file exists + if (publicKeyFileName.isNotEmpty && + !File(publicKeyFileName.replaceAll('.pub', '')).existsSync()) { + logger.info( + 'Unable to find matching ssh private key for public key: $publicKeyFileName'); + throw ('Unable to find matching ssh private key for public key : $publicKeyFileName'); + } + + remoteUsername = params.remoteUsername ?? await fetchRemoteUserName(); + + // find a spare local port + if (localPort == 0) { + logger.info('Finding a spare local port'); + try { + ServerSocket serverSocket = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + localPort = serverSocket.port; + await serverSocket.close(); + } catch (e, s) { + logger.info('Unable to find a spare local port'); + throw SSHNPError('Unable to find a spare local port', + error: e, stackTrace: s); + } + } + + await sharePublicKeyWithSshnpdIfRequired(); + + // If host has an @ then contact the sshrvd service for some ports + if (host.startsWith('@')) { + logger.info('Host is an atSign, fetching host and port from sshrvd'); + await getHostAndPortFromSshrvd(); + } + + logger.finer('Base initialization complete'); + // N.B. Don't complete initialization here, subclasses will do that + // This is in case they need to implement further initialization steps + } + + @visibleForTesting + void handleSshnpdResponses(AtNotification notification) async { + String notificationKey = notification.key + .replaceAll('${notification.to}:', '') + .replaceAll('.$namespace${notification.from}', '') + // convert to lower case as the latest AtClient converts notification + // keys to lower case when received + .toLowerCase(); + logger.info('Received $notificationKey notification'); + + bool connected = false; + + if (notification.value == 'connected') { + connected = true; + } else if (notification.value?.startsWith('{') ?? false) { + late final Map envelope; + late final Map daemonResponse; + try { + envelope = jsonDecode(notification.value!); + assertValidValue(envelope, 'signature', String); + assertValidValue(envelope, 'hashingAlgo', String); + assertValidValue(envelope, 'signingAlgo', String); + + daemonResponse = envelope['payload'] as Map; + assertValidValue(daemonResponse, 'sessionId', String); + assertValidValue(daemonResponse, 'ephemeralPrivateKey', String); + } catch (e) { + logger.warning( + 'Failed to extract parameters from notification value "${notification.value}" with error : $e'); + sshnpdAck = true; + sshnpdAckErrors = true; + return; + } + + try { + await verifyEnvelopeSignature(atClient, sshnpdAtSign, logger, envelope); + } catch (e) { + logger.shout('Failed to verify signature of msg from $sshnpdAtSign'); + logger.shout('Exception: $e'); + logger.shout('Notification value: ${notification.value}'); + sshnpdAck = true; + sshnpdAckErrors = true; + return; + } + + ephemeralPrivateKey = daemonResponse['ephemeralPrivateKey']; + connected = true; + } + + if (connected) { + logger.info('Session $sessionId connected successfully'); + sshnpdAck = true; + } else { + sshnpdAck = true; + sshnpdAckErrors = true; + } + } + + // ==================================================================== + // Internal methods + // ==================================================================== + + Future startAndWaitForInit() async { + if (!initializedCompleter.isCompleted) { + // Call init in case it hasn't been called yet + unawaited(init()); + } + // Wait for init to complete + // N.B. must be called this way in case the init call above is not the first init call + return await initialized; + } + + @protected + Future notify(AtKey atKey, String value, + {String sessionId = ""}) async { + await atClient.notificationService + .notify(NotificationParams.forUpdate(atKey, value: value), + onSuccess: (NotificationResult notification) { + logger.info( + 'SUCCESS:$notification for: $sessionId with key: ${atKey.toString()}'); + }, onError: (notification) { + logger.info('ERROR:$notification'); + }); + } + + /// 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 + @protected + Future fetchRemoteUserName() async { + logger.info('Fetching remote username from sshnpd'); + AtKey userNameRecordID = + AtKey.fromString('$clientAtSign:username.$namespace$sshnpdAtSign'); + try { + return (await atClient.get(userNameRecordID)).value as String; + } catch (e, s) { + await cleanUp(); + throw SSHNPError( + "Device unknown, or username not shared\n" + "hint: make sure the device shares username or set remote username manually", + error: e, + stackTrace: s, + ); + } + } + + @protected + Future getHostAndPortFromSshrvd() async { + atClient.notificationService + .subscribe( + regex: '$sessionId.${SSHRVD.namespace}@', shouldDecrypt: true) + .listen((notification) async { + String ipPorts = notification.value.toString(); + List results = ipPorts.split(','); + host = results[0]; + port = int.parse(results[1]); + sshrvdPort = int.parse(results[2]); + logger.info('Received host and port from sshrvd: $host:$port'); + logger.info('Set sshrvdPort to: $sshrvdPort'); + sshrvdAck = true; + }); + logger.info('Started listening for sshrvd response'); + AtKey ourSshrvdIdKey = AtKey() + ..key = '${params.device}.${SSHRVD.namespace}' + ..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); + logger.info('Sending notification to sshrvd: $ourSshrvdIdKey'); + await notify(ourSshrvdIdKey, sessionId); + + logger.info('Waiting for sshrvd response'); + int counter = 0; + while (!sshrvdAck) { + logger.info('Waiting for sshrvd response: $counter'); + await Future.delayed(Duration(milliseconds: 100)); + counter++; + if (counter == 100) { + logger.warning('Timed out waiting for sshrvd response'); + await cleanUp(); + throw ('Connection timeout to sshrvd $host service\nhint: make sure host is valid and online'); + } + } + } + + Future sharePublicKeyWithSshnpdIfRequired() async { + if (publicKeyFileName.isEmpty) { + logger.info('Skipped sharing public key with sshnpd: none provided'); + return; + } + + logger.info('Sharing public key with sshnpd: $publicKeyFileName'); + try { + String toSshPublicKey = await File(publicKeyFileName).readAsString(); + if (!toSshPublicKey.startsWith('ssh-')) { + logger + .severe('$publicKeyFileName does not look like a public key file'); + throw ('$publicKeyFileName does not look like a public key file'); + } + AtKey sendOurPublicKeyToSshnpd = AtKey() + ..key = 'sshpublickey' + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000); + await notify(sendOurPublicKeyToSshnpd, toSshPublicKey); + } catch (e, s) { + await cleanUp(); + throw SSHNPError( + 'Error opening or validating public key file or sending to remote atSign', + error: e, + stackTrace: s, + ); + } + } + + @protected + Future waitForDaemonResponse() async { + logger.finer('Waiting for daemon response'); + 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) { + return false; + } + } + return true; + } + + Future> _getAtKeysRemote( + {String? regex, + String? sharedBy, + String? sharedWith, + bool showHiddenKeys = false}) async { + var builder = ScanVerbBuilder() + ..sharedWith = sharedWith + ..sharedBy = sharedBy + ..regex = regex + ..showHiddenKeys = showHiddenKeys + ..auth = true; + var scanResult = await atClient.getRemoteSecondary()?.executeVerb(builder); + scanResult = scanResult?.replaceFirst('data:', '') ?? ''; + var result = []; + if (scanResult.isNotEmpty) { + result = List.from(jsonDecode(scanResult)).map((key) { + try { + return AtKey.fromString(key); + } on InvalidSyntaxException { + logger.severe('$key is not a well-formed key'); + } on Exception catch (e) { + logger.severe( + 'Exception occurred: ${e.toString()}. Unable to form key $key'); + } + }).toList(); + } + result.removeWhere((element) => element == null); + return result.cast(); + } + + // ==================================================================== + // Public API + // ==================================================================== + + @override + Future<(Iterable, Iterable, Map)> + listDevices() async { + // get all the keys device_info.*.sshnpd + var scanRegex = + 'device_info\\.$sshnpDeviceNameRegex\\.${DefaultArgs.namespace}'; + + var atKeys = + await _getAtKeysRemote(regex: scanRegex, sharedBy: sshnpdAtSign); + + var devices = {}; + var heartbeats = {}; + var info = {}; + + // Listen for heartbeat notifications + atClient.notificationService + .subscribe( + regex: 'heartbeat\\.$sshnpDeviceNameRegex', shouldDecrypt: true) + .listen((notification) { + var deviceInfo = jsonDecode(notification.value ?? '{}'); + var devicename = deviceInfo['devicename']; + if (devicename != null) { + heartbeats.add(devicename); + } + }); + + // for each key, get the value + for (var entryKey in atKeys) { + var atValue = await atClient.get( + entryKey, + getRequestOptions: GetRequestOptions()..bypassCache = true, + ); + var deviceInfo = jsonDecode(atValue.value) ?? {}; + + if (deviceInfo['devicename'] == null) { + continue; + } + + var devicename = deviceInfo['devicename'] as String; + info[devicename] = deviceInfo; + + var metaData = Metadata() + ..isPublic = false + ..isEncrypted = true + ..ttr = -1 + ..namespaceAware = true; + + var pingKey = AtKey() + ..key = "ping.$devicename" + ..sharedBy = clientAtSign + ..sharedWith = entryKey.sharedBy + ..namespace = DefaultArgs.namespace + ..metadata = metaData; + + unawaited(notify(pingKey, 'ping')); + + // Add the device to the base list + devices.add(devicename); + } + + // wait for 10 seconds in case any are being slow + await Future.delayed(const Duration(seconds: 10)); + + // The intersection is in place on the off chance that some random device + // sends a heartbeat notification, but is not on the list of devices + return ( + devices.intersection(heartbeats), + devices.difference(heartbeats), + info, + ); + } + + @override + FutureOr cleanUp() { + logger.info('Cleaning up SSHNPImpl'); + // This is an intentional no-op to allow overrides to safely call super.cleanUp() + } +} diff --git a/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_legacy_impl.dart b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_legacy_impl.dart new file mode 100644 index 000000000..d1dceb0c7 --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_legacy_impl.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:at_client/at_client.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_reverse_direction.dart'; +import 'package:noports_core/sshnp.dart'; +import 'package:noports_core/sshrv.dart'; + +class SSHNPLegacyImpl extends SSHNPImpl with SSHNPReverseDirection { + SSHNPLegacyImpl({ + required AtClient atClient, + required SSHNPParams params, + SSHRVGenerator? sshrvGenerator, + bool? shouldInitialize, + }) : super( + atClient: atClient, + params: params, + sshrvGenerator: sshrvGenerator, + shouldInitialize: shouldInitialize, + ); + + @override + Future init() async { + await super.init(); + + // Share our private key with sshnpd + AtKey sendOurPrivateKeyToSshnpd = AtKey() + ..key = 'privatekey' + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..namespace = this.namespace + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000); + await notify(sendOurPrivateKeyToSshnpd, sshPrivateKey); + + initializedCompleter.complete(); + } + + @override + Future run() async { + await startAndWaitForInit(); + + logger.info('Requesting legacy daemon to start reverse ssh session'); + + Future? sshrvResult; + if (usingSshrv) { + // Connect to rendezvous point using background process. + // sshnp (this program) can then exit without issue. + SSHRV sshrv = sshrvGenerator(host, sshrvdPort!, + localSshdPort: params.localSshdPort); + sshrvResult = sshrv.run(); + } + + // send request to the daemon via notification + await notify( + AtKey() + ..key = 'sshd' + ..namespace = this.namespace + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000), + '$localPort $port ${params.username} $host $sessionId', + sessionId: sessionId, + ); + + bool acked = await waitForDaemonResponse(); + await cleanUp(); + if (!acked) { + var error = SSHNPError( + 'sshnp timed out: waiting for daemon response\nhint: make sure the device is online', + stackTrace: StackTrace.current, + ); + doneCompleter.completeError(error); + return error; + } + + if (sshnpdAckErrors) { + var error = SSHNPError( + 'sshnp failed: with sshnpd acknowledgement errors', + stackTrace: StackTrace.current, + ); + doneCompleter.completeError(error); + return error; + } + + doneCompleter.complete(); + return SSHNPCommand( + localPort: localPort, + remoteUsername: remoteUsername, + host: 'localhost', + privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), + localSshOptions: + (params.addForwardsToTunnel) ? null : params.localSshOptions, + connectionBean: sshrvResult, + ); + } +} diff --git a/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_reverse_direction.dart b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_reverse_direction.dart new file mode 100644 index 000000000..9f2e14e16 --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_reverse_direction.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_result.dart'; +import 'package:noports_core/utils.dart'; + +mixin SSHNPReverseDirection on SSHNPImpl { + /// Set by [generateSshKeys] during [init], if we're not doing direct ssh. + /// 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; + + @override + Future init() async { + await super.init(); + logger.info('Generating ephemeral keypair'); + try { + var (String ephemeralPublicKey, String ephemeralPrivateKey) = + await generateSshKeys( + rsa: params.rsa, + sessionId: sessionId, + sshHomeDirectory: sshHomeDirectory, + ); + sshPublicKey = ephemeralPublicKey; + sshPrivateKey = ephemeralPrivateKey; + } catch (e, s) { + logger.info('Failed to generate ephemeral keypair'); + throw SSHNPError( + 'Failed to generate ephemeral keypair', + error: e, + stackTrace: s, + ); + } + + try { + logger.info('Adding ephemeral key to authorized_keys'); + await addEphemeralKeyToAuthorizedKeys( + sshPublicKey: sshPublicKey, + localSshdPort: params.localSshdPort, + sessionId: sessionId); + } catch (e, s) { + throw SSHNPError( + 'Failed to add ephemeral key to authorized_keys', + error: e, + stackTrace: s, + ); + } + } + + @override + Future cleanUp() async { + await cleanUpAfterReverseSsh(this); + super.cleanUp(); + } + + bool get usingSshrv => sshrvdPort != null; +} \ No newline at end of file diff --git a/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_reverse_impl.dart b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_reverse_impl.dart new file mode 100644 index 000000000..2df4a8ec6 --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_impl/sshnp_reverse_impl.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:at_client/at_client.dart'; +import 'package:noports_core/src/common/utils.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_impl.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_reverse_direction.dart'; +import 'package:noports_core/sshnp.dart'; +import 'package:noports_core/sshrv.dart'; + +class SSHNPReverseImpl extends SSHNPImpl with SSHNPReverseDirection { + SSHNPReverseImpl({ + required AtClient atClient, + required SSHNPParams params, + SSHRVGenerator? sshrvGenerator, + bool? shouldInitialize, + }) : super( + atClient: atClient, + params: params, + sshrvGenerator: sshrvGenerator, + shouldInitialize: shouldInitialize, + ); + + @override + Future init() async { + await super.init(); + initializedCompleter.complete(); + } + + @override + Future run() async { + await startAndWaitForInit(); + + logger.info('Requesting daemon to start reverse ssh session'); + + Future? sshrvResult; + if (usingSshrv) { + // Connect to rendezvous point using background process. + // sshnp (this program) can then exit without issue. + SSHRV sshrv = sshrvGenerator(host, sshrvdPort!, + localSshdPort: params.localSshdPort); + sshrvResult = sshrv.run(); + } + // send request to the daemon via notification + await notify( + AtKey() + ..key = 'ssh_request' + ..namespace = this.namespace + ..sharedBy = clientAtSign + ..sharedWith = sshnpdAtSign + ..metadata = (Metadata() + ..ttr = -1 + ..ttl = 10000), + signAndWrapAndJsonEncode(atClient, { + 'direct': false, + 'sessionId': sessionId, + 'host': host, + 'port': port, + 'username': params.username, + 'remoteForwardPort': localPort, + 'privateKey': sshPrivateKey + }), + sessionId: sessionId); + + bool acked = await waitForDaemonResponse(); + await cleanUp(); + if (!acked) { + var error = + SSHNPError('sshnp connection timeout: waiting for daemon response'); + doneCompleter.completeError(error); + return error; + } + + if (sshnpdAckErrors) { + var error = + SSHNPError('sshnp failed: with sshnpd acknowledgement errors'); + doneCompleter.completeError(error); + return error; + } + + doneCompleter.complete(); + return SSHNPCommand( + localPort: localPort, + remoteUsername: remoteUsername, + host: 'localhost', + privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), + localSshOptions: + (params.addForwardsToTunnel) ? null : params.localSshOptions, + connectionBean: sshrvResult, + ); + } +} diff --git a/packages/noports_core/lib/sshnp/config_repository/config_file_repository.dart b/packages/noports_core/lib/src/sshnp/sshnp_params/config_file_repository.dart similarity index 96% rename from packages/noports_core/lib/sshnp/config_repository/config_file_repository.dart rename to packages/noports_core/lib/src/sshnp/sshnp_params/config_file_repository.dart index 12e7e6dd5..86670c74f 100644 --- a/packages/noports_core/lib/sshnp/config_repository/config_file_repository.dart +++ b/packages/noports_core/lib/src/sshnp/sshnp_params/config_file_repository.dart @@ -1,8 +1,8 @@ import 'dart:io'; -import 'package:noports_core/common/utils.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; -import 'package:noports_core/sshnp/sshnp_arg.dart'; +import 'package:noports_core/src/common/utils.dart'; +import 'package:noports_core/src/sshnp/sshnp_params/sshnp_params.dart'; +import 'package:noports_core/src/sshnp/sshnp_params/sshnp_arg.dart'; import 'package:path/path.dart' as path; class ConfigFileRepository { diff --git a/packages/noports_core/lib/sshnp/config_repository/config_key_repository.dart b/packages/noports_core/lib/src/sshnp/sshnp_params/config_key_repository.dart similarity index 90% rename from packages/noports_core/lib/sshnp/config_repository/config_key_repository.dart rename to packages/noports_core/lib/src/sshnp/sshnp_params/config_key_repository.dart index 05b540916..087a34d3f 100644 --- a/packages/noports_core/lib/sshnp/config_repository/config_key_repository.dart +++ b/packages/noports_core/lib/src/sshnp/sshnp_params/config_key_repository.dart @@ -1,10 +1,10 @@ import 'package:at_client/at_client.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; -import 'package:noports_core/sshnpd/sshnpd.dart'; +import 'package:noports_core/src/common/default_args.dart'; +import 'package:noports_core/src/sshnp/sshnp_params/sshnp_params.dart'; class ConfigKeyRepository { static const String _keyPrefix = 'profile_'; - static const String _configNamespace = 'profiles.${SSHNPD.namespace}'; + static const String _configNamespace = 'profiles.${DefaultArgs.namespace}'; static String toProfileName(AtKey atKey, {bool replaceSpaces = true}) { var profileName = atKey.key!.split('.').first; diff --git a/packages/noports_core/lib/sshnp/sshnp_arg.dart b/packages/noports_core/lib/src/sshnp/sshnp_params/sshnp_arg.dart similarity index 62% rename from packages/noports_core/lib/sshnp/sshnp_arg.dart rename to packages/noports_core/lib/src/sshnp/sshnp_params/sshnp_arg.dart index 26448ab2d..cf4bdd9bb 100644 --- a/packages/noports_core/lib/sshnp/sshnp_arg.dart +++ b/packages/noports_core/lib/src/sshnp/sshnp_params/sshnp_arg.dart @@ -1,8 +1,7 @@ import 'package:args/args.dart'; -import 'package:noports_core/common/supported_ssh_clients.dart'; +import 'package:noports_core/src/common/supported_ssh_clients.dart'; -import 'package:noports_core/common/defaults.dart'; -import 'sshnp.dart'; +import 'package:noports_core/src/common/default_args.dart'; enum ArgFormat { option, @@ -45,6 +44,8 @@ class SSHNPArg { String get bashName => name.replaceAll('-', '_').toUpperCase(); + List get aliasList => ['--$name', ...aliases?.map((e) => '--$e') ?? [], '-$abbr']; + factory SSHNPArg.noArg() { return SSHNPArg(name: ''); } @@ -64,6 +65,13 @@ class SSHNPArg { } static List args = [ + const SSHNPArg( + name: 'help', + help: 'Print this usage information', + defaultsTo: DefaultArgs.help, + format: ArgFormat.flag, + commandLineOnly: true, + ), const SSHNPArg( name: 'key-file', abbr: 'k', @@ -85,7 +93,7 @@ class SSHNPArg { name: 'device', abbr: 'd', help: 'Receiving device name', - defaultsTo: SSHNP.defaultDevice, + defaultsTo: DefaultSSHNPArgs.device, ), const SSHNPArg( name: 'host', @@ -96,22 +104,26 @@ class SSHNPArg { const SSHNPArg( name: 'port', abbr: 'p', - help: 'TCP port to connect back to (only required if --host specified a FQDN/IP)', - defaultsTo: SSHNP.defaultPort, + help: + 'TCP port to connect back to (only required if --host specified a FQDN/IP)', + defaultsTo: DefaultSSHNPArgs.port, type: ArgType.integer, ), const SSHNPArg( name: 'local-port', abbr: 'l', - help: 'Reverse ssh port to listen on, on your local machine, by sshnp default finds a spare port', - defaultsTo: SSHNP.defaultLocalPort, + help: + 'Reverse ssh port to listen on, on your local machine, by sshnp default finds a spare port', + defaultsTo: DefaultSSHNPArgs.localPort, type: ArgType.integer, ), const SSHNPArg( name: 'ssh-public-key', abbr: 's', - help: 'Public key file from ~/.ssh to be appended to authorized_hosts on the remote device', - defaultsTo: SSHNP.defaultSendSshPublicKey, + help: + 'Public key file from ~/.ssh to be appended to authorized_hosts on the remote device', + defaultsTo: DefaultSSHNPArgs.sendSshPublicKey, + commandLineOnly: true, ), const SSHNPArg( name: 'local-ssh-options', @@ -122,14 +134,14 @@ class SSHNPArg { const SSHNPArg( name: 'verbose', abbr: 'v', - defaultsTo: defaultVerbose, + defaultsTo: DefaultArgs.verbose, help: 'More logging', format: ArgFormat.flag, ), const SSHNPArg( name: 'rsa', abbr: 'r', - defaultsTo: defaultRsa, + defaultsTo: DefaultArgs.rsa, help: 'Use RSA 4096 keys rather than the default ED25519 keys', format: ArgFormat.flag, ), @@ -141,14 +153,14 @@ class SSHNPArg { const SSHNPArg( name: 'root-domain', help: 'atDirectory domain', - defaultsTo: defaultRootDomain, + defaultsTo: DefaultArgs.rootDomain, mandatory: false, format: ArgFormat.option, ), const SSHNPArg( name: 'local-sshd-port', help: 'port on which sshd is listening locally on the client host', - defaultsTo: defaultLocalSshdPort, + defaultsTo: DefaultArgs.localSshdPort, abbr: 'P', mandatory: false, format: ArgFormat.option, @@ -156,22 +168,23 @@ class SSHNPArg { ), const SSHNPArg( name: 'legacy-daemon', - defaultsTo: SSHNP.defaultLegacyDaemon, + defaultsTo: DefaultSSHNPArgs.legacyDaemon, help: 'Request is to a legacy (< 4.0.0) noports daemon', format: ArgFormat.flag, ), const SSHNPArg( name: 'remote-sshd-port', help: 'port on which sshd is listening locally on the device host', - defaultsTo: defaultRemoteSshdPort, + defaultsTo: DefaultArgs.remoteSshdPort, mandatory: false, format: ArgFormat.option, type: ArgType.integer, ), const SSHNPArg( name: 'idle-timeout', - help: 'number of seconds after which inactive ssh connections will be closed', - defaultsTo: defaultIdleTimeout, + help: + 'number of seconds after which inactive ssh connections will be closed', + defaultsTo: DefaultArgs.idleTimeout, mandatory: false, format: ArgFormat.option, type: ArgType.integer, @@ -180,7 +193,7 @@ class SSHNPArg { SSHNPArg( name: 'ssh-client', help: 'What to use for outbound ssh connections', - defaultsTo: SupportedSshClient.hostSsh.cliArg, + defaultsTo: SupportedSshClient.exec.cliArg, mandatory: false, format: ArgFormat.option, type: ArgType.string, @@ -197,7 +210,8 @@ class SSHNPArg { ), const SSHNPArg( name: 'config-file', - help: 'Read args from a config file\nMandatory args are not required if already supplied in the config file', + help: + 'Read args from a config file\nMandatory args are not required if already supplied in the config file', commandLineOnly: true, ), const SSHNPArg( @@ -213,48 +227,52 @@ class SSHNPArg { String toString() { return 'SSHNPArg{format: $format, name: $name, abbr: $abbr, help: $help, mandatory: $mandatory, defaultsTo: $defaultsTo, type: $type}'; } -} -ArgParser createArgParser({ - bool isCommandLine = true, - bool withDefaults = true, -}) { - var parser = ArgParser(); - // Basic arguments - for (SSHNPArg arg in SSHNPArg.args) { - if (arg.commandLineOnly && !isCommandLine) { - continue; - } - switch (arg.format) { - case ArgFormat.option: - parser.addOption( - arg.name, - abbr: arg.abbr, - mandatory: arg.mandatory, - defaultsTo: withDefaults ? arg.defaultsTo?.toString() : null, - help: arg.help, - allowed: arg.allowed, - aliases: arg.aliases ?? const [], - ); - break; - case ArgFormat.multiOption: - parser.addMultiOption( - arg.name, - abbr: arg.abbr, - defaultsTo: withDefaults ? arg.defaultsTo as List? : null, - help: arg.help, - ); - break; - case ArgFormat.flag: - parser.addFlag( - arg.name, - abbr: arg.abbr, - defaultsTo: withDefaults ? arg.defaultsTo as bool? : null, - help: arg.help, - negatable: arg.negatable, - ); - break; + static ArgParser createArgParser({ + bool isCommandLine = true, + bool withDefaults = true, + Iterable? includeList, + }) { + var parser = ArgParser(); + // Basic arguments + for (SSHNPArg arg in SSHNPArg.args) { + if (includeList != null && !includeList.contains(arg.name)) { + continue; + } + if (arg.commandLineOnly && !isCommandLine) { + continue; + } + switch (arg.format) { + case ArgFormat.option: + parser.addOption( + arg.name, + abbr: arg.abbr, + mandatory: arg.mandatory, + defaultsTo: withDefaults ? arg.defaultsTo?.toString() : null, + help: arg.help, + allowed: arg.allowed, + aliases: arg.aliases ?? const [], + ); + break; + case ArgFormat.multiOption: + parser.addMultiOption( + arg.name, + abbr: arg.abbr, + defaultsTo: withDefaults ? arg.defaultsTo as List? : null, + help: arg.help, + ); + break; + case ArgFormat.flag: + parser.addFlag( + arg.name, + abbr: arg.abbr, + defaultsTo: withDefaults ? arg.defaultsTo as bool? : null, + help: arg.help, + negatable: arg.negatable, + ); + break; + } } + return parser; } - return parser; } diff --git a/packages/noports_core/lib/sshnp/sshnp_params.dart b/packages/noports_core/lib/src/sshnp/sshnp_params/sshnp_params.dart similarity index 71% rename from packages/noports_core/lib/sshnp/sshnp_params.dart rename to packages/noports_core/lib/src/sshnp/sshnp_params/sshnp_params.dart index 447e4713c..6406277f8 100644 --- a/packages/noports_core/lib/sshnp/sshnp_params.dart +++ b/packages/noports_core/lib/src/sshnp/sshnp_params/sshnp_params.dart @@ -1,4 +1,10 @@ -part of 'sshnp.dart'; +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:noports_core/src/common/utils.dart'; +import 'package:noports_core/src/sshnp/sshnp_params/config_file_repository.dart'; +import 'package:noports_core/src/sshnp/sshnp_params/sshnp_arg.dart'; +import 'package:noports_core/src/common/default_args.dart'; class SSHNPParams { /// Required Arguments @@ -6,9 +12,9 @@ class SSHNPParams { /// Since there are multiple sources for these values, we cannot validate /// that they will be provided. If any are null, then the caller must /// handle the error. - final String? clientAtSign; - final String? sshnpdAtSign; - final String? host; + final String clientAtSign; + final String sshnpdAtSign; + final String host; /// Optional Arguments final String device; @@ -33,7 +39,8 @@ class SSHNPParams { late final String sshClient; /// Special Arguments - final String? profileName; // automatically populated with the filename if from a configFile + final String? + profileName; // automatically populated with the filename if from a configFile final bool listDevices; SSHNPParams({ @@ -41,21 +48,21 @@ class SSHNPParams { required this.sshnpdAtSign, required this.host, this.profileName, - this.device = SSHNP.defaultDevice, - this.port = SSHNP.defaultPort, - this.localPort = SSHNP.defaultLocalPort, - this.sendSshPublicKey = SSHNP.defaultSendSshPublicKey, - this.localSshOptions = SSHNP.defaultLocalSshOptions, - this.verbose = defaults.defaultVerbose, - this.rsa = defaults.defaultRsa, + this.device = DefaultSSHNPArgs.device, + this.port = DefaultSSHNPArgs.port, + this.localPort = DefaultSSHNPArgs.localPort, + this.sendSshPublicKey = DefaultSSHNPArgs.sendSshPublicKey, + this.localSshOptions = DefaultSSHNPArgs.localSshOptions, + this.verbose = DefaultArgs.verbose, + this.rsa = DefaultArgs.rsa, this.remoteUsername, String? atKeysFilePath, - this.rootDomain = defaults.defaultRootDomain, - this.localSshdPort = defaults.defaultLocalSshdPort, - this.legacyDaemon = SSHNP.defaultLegacyDaemon, - this.listDevices = SSHNP.defaultListDevices, - this.remoteSshdPort = defaults.defaultRemoteSshdPort, - this.idleTimeout = defaults.defaultIdleTimeout, + this.rootDomain = DefaultArgs.rootDomain, + this.localSshdPort = DefaultArgs.localSshdPort, + this.legacyDaemon = DefaultSSHNPArgs.legacyDaemon, + this.listDevices = DefaultSSHNPArgs.listDevices, + this.remoteSshdPort = DefaultArgs.remoteSshdPort, + this.idleTimeout = DefaultArgs.idleTimeout, String? sshClient, this.addForwardsToTunnel = false, }) { @@ -67,9 +74,10 @@ class SSHNPParams { // Use default atKeysFilePath if not provided - this.atKeysFilePath = atKeysFilePath ?? getDefaultAtKeysFilePath(homeDirectory, clientAtSign); + this.atKeysFilePath = + atKeysFilePath ?? getDefaultAtKeysFilePath(homeDirectory, clientAtSign); - this.sshClient = sshClient ?? SSHNP.defaultSshClient.cliArg; + this.sshClient = sshClient ?? DefaultSSHNPArgs.sshClient.cliArg; } factory SSHNPParams.empty() { @@ -83,7 +91,8 @@ class SSHNPParams { /// Merge an SSHNPPartialParams objects into an SSHNPParams /// Params in params2 take precedence over params1 - factory SSHNPParams.merge(SSHNPParams params1, [SSHNPPartialParams? params2]) { + factory SSHNPParams.merge(SSHNPParams params1, + [SSHNPPartialParams? params2]) { params2 ??= SSHNPPartialParams.empty(); return SSHNPParams( profileName: params2.profileName ?? params1.profileName, @@ -106,7 +115,8 @@ class SSHNPParams { remoteSshdPort: params2.remoteSshdPort ?? params1.remoteSshdPort, idleTimeout: params2.idleTimeout ?? params1.idleTimeout, sshClient: params2.sshClient ?? params1.sshClient, - addForwardsToTunnel: params2.addForwardsToTunnel ?? params1.addForwardsToTunnel, + addForwardsToTunnel: + params2.addForwardsToTunnel ?? params1.addForwardsToTunnel, ); } @@ -114,44 +124,44 @@ class SSHNPParams { return SSHNPParams.fromPartial(SSHNPPartialParams.fromFile(fileName)); } - factory SSHNPParams.fromJson(String json) => SSHNPParams.fromPartial(SSHNPPartialParams.fromJson(json)); + factory SSHNPParams.fromJson(String json) => + SSHNPParams.fromPartial(SSHNPPartialParams.fromJson(json)); factory SSHNPParams.fromPartial(SSHNPPartialParams partial) { - AtSignLogger logger = AtSignLogger(' SSHNPParams '); - - /// If any required params are null log severe, but do not throw - /// The caller must handle the error if any required params are null - partial.clientAtSign ?? (logger.severe('clientAtSign is null')); - partial.sshnpdAtSign ?? (logger.severe('sshnpdAtSign is null')); - partial.host ?? (logger.severe('host is null')); + partial.clientAtSign ?? (throw ArgumentError('from is mandatory')); + partial.sshnpdAtSign ?? (throw ArgumentError('to is mandatory')); + partial.host ?? (throw ArgumentError('host is mandatory')); return SSHNPParams( profileName: partial.profileName, - clientAtSign: partial.clientAtSign, - sshnpdAtSign: partial.sshnpdAtSign, - host: partial.host, - device: partial.device ?? SSHNP.defaultDevice, - port: partial.port ?? SSHNP.defaultPort, - localPort: partial.localPort ?? SSHNP.defaultLocalPort, - sendSshPublicKey: partial.sendSshPublicKey ?? SSHNP.defaultSendSshPublicKey, - localSshOptions: partial.localSshOptions ?? SSHNP.defaultLocalSshOptions, - rsa: partial.rsa ?? defaults.defaultRsa, - verbose: partial.verbose ?? defaults.defaultVerbose, + clientAtSign: partial.clientAtSign!, + sshnpdAtSign: partial.sshnpdAtSign!, + host: partial.host!, + device: partial.device ?? DefaultSSHNPArgs.device, + port: partial.port ?? DefaultSSHNPArgs.port, + localPort: partial.localPort ?? DefaultSSHNPArgs.localPort, + sendSshPublicKey: + partial.sendSshPublicKey ?? DefaultSSHNPArgs.sendSshPublicKey, + localSshOptions: + partial.localSshOptions ?? DefaultSSHNPArgs.localSshOptions, + rsa: partial.rsa ?? DefaultArgs.rsa, + verbose: partial.verbose ?? DefaultArgs.verbose, remoteUsername: partial.remoteUsername, atKeysFilePath: partial.atKeysFilePath, - rootDomain: partial.rootDomain ?? defaults.defaultRootDomain, - localSshdPort: partial.localSshdPort ?? defaults.defaultLocalSshdPort, - listDevices: partial.listDevices ?? SSHNP.defaultListDevices, - legacyDaemon: partial.legacyDaemon ?? SSHNP.defaultLegacyDaemon, - remoteSshdPort: partial.remoteSshdPort ?? defaults.defaultRemoteSshdPort, - idleTimeout: partial.idleTimeout ?? defaults.defaultIdleTimeout, - sshClient: partial.sshClient ?? SSHNP.defaultSshClient.cliArg, + rootDomain: partial.rootDomain ?? DefaultArgs.rootDomain, + localSshdPort: partial.localSshdPort ?? DefaultArgs.localSshdPort, + listDevices: partial.listDevices ?? DefaultSSHNPArgs.listDevices, + legacyDaemon: partial.legacyDaemon ?? DefaultSSHNPArgs.legacyDaemon, + remoteSshdPort: partial.remoteSshdPort ?? DefaultArgs.remoteSshdPort, + idleTimeout: partial.idleTimeout ?? DefaultArgs.idleTimeout, + sshClient: partial.sshClient ?? DefaultSSHNPArgs.sshClient.cliArg, addForwardsToTunnel: partial.addForwardsToTunnel ?? false, ); } factory SSHNPParams.fromConfig(String profileName, List lines) { - return SSHNPParams.fromPartial(SSHNPPartialParams.fromConfig(profileName, lines)); + return SSHNPParams.fromPartial( + SSHNPPartialParams.fromConfig(profileName, lines)); } Map toArgs() { @@ -203,7 +213,7 @@ class SSHNPParams { /// e.g. default values from a config file and the rest from the command line class SSHNPPartialParams { // Non param variables - static final ArgParser parser = createArgParser(); + static final ArgParser parser = SSHNPArg.createArgParser(); /// Main Params final String? profileName; @@ -247,8 +257,8 @@ class SSHNPPartialParams { this.verbose, this.rootDomain, this.localSshdPort, - this.listDevices = SSHNP.defaultListDevices, - this.legacyDaemon = SSHNP.defaultLegacyDaemon, + this.listDevices = DefaultSSHNPArgs.listDevices, + this.legacyDaemon = DefaultSSHNPArgs.legacyDaemon, this.remoteSshdPort, this.idleTimeout, this.sshClient, @@ -261,7 +271,8 @@ class SSHNPPartialParams { /// Merge two SSHNPPartialParams objects together /// Params in params2 take precedence over params1 - factory SSHNPPartialParams.merge(SSHNPPartialParams params1, [SSHNPPartialParams? params2]) { + factory SSHNPPartialParams.merge(SSHNPPartialParams params1, + [SSHNPPartialParams? params2]) { params2 ??= SSHNPPartialParams.empty(); return SSHNPPartialParams( profileName: params2.profileName ?? params1.profileName, @@ -284,7 +295,8 @@ class SSHNPPartialParams { remoteSshdPort: params2.remoteSshdPort ?? params1.remoteSshdPort, idleTimeout: params2.idleTimeout ?? params1.idleTimeout, sshClient: params2.sshClient ?? params1.sshClient, - addForwardsToTunnel: params2.addForwardsToTunnel ?? params1.addForwardsToTunnel, + addForwardsToTunnel: + params2.addForwardsToTunnel ?? params1.addForwardsToTunnel, ); } @@ -294,13 +306,15 @@ class SSHNPPartialParams { return SSHNPPartialParams.fromMap(args); } - factory SSHNPPartialParams.fromConfig(String profileName, List lines) { + factory SSHNPPartialParams.fromConfig( + String profileName, List lines) { var args = ConfigFileRepository.parseConfigFileContents(lines); args['profile-name'] = profileName; return SSHNPPartialParams.fromMap(args); } - factory SSHNPPartialParams.fromJson(String json) => SSHNPPartialParams.fromMap(jsonDecode(json)); + factory SSHNPPartialParams.fromJson(String json) => + SSHNPPartialParams.fromMap(jsonDecode(json)); factory SSHNPPartialParams.fromMap(Map args) { return SSHNPPartialParams( @@ -333,7 +347,7 @@ class SSHNPPartialParams { factory SSHNPPartialParams.fromArgs(List args) { var params = SSHNPPartialParams.empty(); - var parsedArgs = createArgParser(withDefaults: false).parse(args); + var parsedArgs = SSHNPArg.createArgParser(withDefaults: false).parse(args); if (parsedArgs.wasParsed('config-file')) { var configFileName = parsedArgs['config-file'] as String; @@ -346,7 +360,9 @@ class SSHNPPartialParams { // THIS IS A WORKAROUND IN ORDER TO BE TYPE SAFE IN SSHNPPartialParams.fromArgMap Map parsedArgsMap = { for (var e in parsedArgs.options) - e: SSHNPArg.fromName(e).type == ArgType.integer ? int.tryParse(parsedArgs[e]) : parsedArgs[e] + e: SSHNPArg.fromName(e).type == ArgType.integer + ? int.tryParse(parsedArgs[e]) + : parsedArgs[e] }; return SSHNPPartialParams.merge( diff --git a/packages/noports_core/lib/src/sshnp/sshnp_result.dart b/packages/noports_core/lib/src/sshnp/sshnp_result.dart new file mode 100644 index 000000000..eca264afe --- /dev/null +++ b/packages/noports_core/lib/src/sshnp/sshnp_result.dart @@ -0,0 +1,136 @@ +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:socket_connector/socket_connector.dart'; + +abstract class SSHNPResult {} + +class SSHNPSuccess implements SSHNPResult {} + +class SSHNPFailure implements SSHNPResult {} + +mixin SSHNPConnectionBean on SSHNPResult { + Bean? _connectionBean; + + @protected + set connectionBean(Bean? connectionBean) { + _connectionBean = connectionBean; + } + + Bean? get connectionBean => _connectionBean; + + Future killConnectionBean() async { + if (_connectionBean is Process) { + (_connectionBean as Process).kill(); + } + + if (_connectionBean is SocketConnector) { + (_connectionBean as SocketConnector).close(); + } + + if (_connectionBean is Future) { + await (_connectionBean as Future).then((value) { + if (value is Process) { + value.kill(); + } + if (value is SocketConnector) { + value.close(); + } + }); + } + } +} + +const _optionsWithPrivateKey = [ + '-o StrictHostKeyChecking=accept-new', + '-o IdentitiesOnly=yes' +]; + +class SSHNPError implements SSHNPFailure, Exception { + final Object message; + final Object? error; + final StackTrace? stackTrace; + + SSHNPError(this.message, {this.error, this.stackTrace}); + + @override + String toString() { + return message.toString(); + } + + String toVerboseString() { + final sb = StringBuffer(); + sb.write(message); + if (error != null) { + sb.write('\n'); + sb.write('Error: $error'); + } + if (stackTrace != null) { + sb.write('\n'); + sb.write('Stack Trace: $stackTrace'); + } + return sb.toString(); + } +} + +class SSHNPCommand extends SSHNPSuccess with SSHNPConnectionBean { + final String command; + final int localPort; + final String? remoteUsername; + final String host; + final String? privateKeyFileName; + + final List sshOptions; + + SSHNPCommand( + {required this.localPort, + required this.remoteUsername, + required this.host, + this.command = 'ssh', + List? localSshOptions, + this.privateKeyFileName, + Bean? connectionBean}) + : sshOptions = [ + if (shouldIncludePrivateKey(privateKeyFileName)) + ..._optionsWithPrivateKey, + ...(localSshOptions ?? []) + ] { + this.connectionBean = connectionBean; + } + + static bool shouldIncludePrivateKey(String? privateKeyFileName) => + privateKeyFileName != null && privateKeyFileName.isNotEmpty; + + List get args => [ + '-p $localPort', + ...sshOptions, + if (remoteUsername != null) '$remoteUsername@$host', + if (remoteUsername == null) host, + if (shouldIncludePrivateKey(privateKeyFileName)) ...[ + '-i', + '$privateKeyFileName' + ], + ]; + + @override + String toString() { + final sb = StringBuffer(); + sb.write(command); + sb.write(' '); + sb.write(args.join(' ')); + return sb.toString(); + } +} + +class SSHNPNoOpSuccess extends SSHNPSuccess + with SSHNPConnectionBean { + String? message; + SSHNPNoOpSuccess({this.message, Bean? connectionBean}) { + this.connectionBean = connectionBean; + } + + @override + String toString() { + return message ?? 'Connection Established'; + } +} diff --git a/packages/noports_core/lib/sshnp/utils.dart b/packages/noports_core/lib/src/sshnp/utils.dart similarity index 72% rename from packages/noports_core/lib/sshnp/utils.dart rename to packages/noports_core/lib/src/sshnp/utils.dart index 5000d4e31..a32d0f293 100644 --- a/packages/noports_core/lib/sshnp/utils.dart +++ b/packages/noports_core/lib/src/sshnp/utils.dart @@ -1,15 +1,22 @@ +import 'dart:async'; import 'dart:io'; -import 'package:noports_core/common/utils.dart'; +import 'package:noports_core/src/common/utils.dart'; import 'package:at_utils/at_logger.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; +import 'package:noports_core/src/sshnp/sshnp.dart'; +import 'package:noports_core/src/sshnp/sshnp_impl/sshnp_reverse_direction.dart'; + +Completer wrapInCompleter(Future future) { + final completer = Completer(); + unawaited( + future.then(completer.complete).catchError(completer.completeError), + ); + return completer; +} Future cleanUpAfterReverseSsh(SSHNP sshnp) async { - if (!sshnp.initialized) { - // never got started, nothing to clean up - return; - } - if (sshnp.direct) { - // did a direct ssh, not a reverse one - nothing to clean up + if (!wrapInCompleter(sshnp.initialized).isCompleted || + sshnp is! SSHNPReverseDirection) { + // nothing to clean up return; } @@ -21,8 +28,10 @@ Future cleanUpAfterReverseSsh(SSHNP sshnp) async { sshnp.logger.info('Tidying up files'); // Delete the generated RSA keys and remove the entry from ~/.ssh/authorized_keys await deleteFile('$sshHomeDirectory/${sshnp.sessionId}_sshnp', sshnp.logger); - await deleteFile('$sshHomeDirectory/${sshnp.sessionId}_sshnp.pub', sshnp.logger); - await removeEphemeralKeyFromAuthorizedKeys(sshnp.sessionId, sshnp.logger, sshHomeDirectory: sshHomeDirectory); + await deleteFile( + '$sshHomeDirectory/${sshnp.sessionId}_sshnp.pub', sshnp.logger); + await removeEphemeralKeyFromAuthorizedKeys(sshnp.sessionId, sshnp.logger, + sshHomeDirectory: sshHomeDirectory); } Future deleteFile(String fileName, AtSignLogger logger) async { diff --git a/packages/noports_core/lib/sshnpd/sshnpd.dart b/packages/noports_core/lib/src/sshnpd/sshnpd.dart similarity index 89% rename from packages/noports_core/lib/sshnpd/sshnpd.dart rename to packages/noports_core/lib/src/sshnpd/sshnpd.dart index 936cd204c..c3682afa4 100644 --- a/packages/noports_core/lib/sshnpd/sshnpd.dart +++ b/packages/noports_core/lib/src/sshnpd/sshnpd.dart @@ -1,29 +1,13 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:args/args.dart'; import 'package:at_client/at_client.dart' hide StringBuffer; import 'package:at_utils/at_logger.dart'; -import 'package:dartssh2/dartssh2.dart'; -import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; - -import 'package:noports_core/common/supported_ssh_clients.dart'; -import 'package:noports_core/common/utils.dart'; -import 'package:noports_core/sshrv/sshrv.dart'; -import 'package:noports_core/version.dart'; - -import 'package:uuid/uuid.dart'; - -import 'package:noports_core/common/defaults.dart' as defaults; - -part 'sshnpd_impl.dart'; -part 'sshnpd_params.dart'; +import 'package:noports_core/src/common/supported_ssh_clients.dart'; +import 'package:noports_core/src/sshnpd/sshnpd_impl.dart'; +import 'package:noports_core/src/sshnpd/sshnpd_params.dart'; abstract class SSHNPD { - static const String namespace = 'sshnp'; - abstract final AtSignLogger logger; /// The [AtClient] used to communicate with sshnpd and sshrvd diff --git a/packages/noports_core/lib/sshnpd/sshnpd_impl.dart b/packages/noports_core/lib/src/sshnpd/sshnpd_impl.dart similarity index 95% rename from packages/noports_core/lib/sshnpd/sshnpd_impl.dart rename to packages/noports_core/lib/src/sshnpd/sshnpd_impl.dart index ff986a609..d6723cf76 100644 --- a/packages/noports_core/lib/sshnpd/sshnpd_impl.dart +++ b/packages/noports_core/lib/src/sshnpd/sshnpd_impl.dart @@ -1,6 +1,21 @@ -part of 'sshnpd.dart'; - -@visibleForTesting +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:at_client/at_client.dart' hide StringBuffer; +import 'package:at_utils/at_logger.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:noports_core/src/common/default_args.dart'; +import 'package:noports_core/src/common/supported_ssh_clients.dart'; +import 'package:noports_core/src/common/utils.dart'; +import 'package:noports_core/src/sshrv/sshrv.dart'; +import 'package:noports_core/sshnpd.dart'; +import 'package:noports_core/version.dart'; +import 'package:uuid/uuid.dart'; + +@protected class SSHNPDImpl implements SSHNPD { @override final AtSignLogger logger = AtSignLogger(' sshnpd '); @@ -60,7 +75,7 @@ class SSHNPDImpl implements SSHNPD { required this.sshClient, this.makeDeviceInfoVisible = false, this.addSshPublicKeys = false, - this.localSshdPort = defaults.defaultLocalSshdPort, + this.localSshdPort = DefaultArgs.localSshdPort, required this.ephemeralPermissions, required this.rsa, }) { @@ -85,7 +100,7 @@ class SSHNPDImpl implements SSHNPD { AtSignLogger.root_level = 'INFO'; } - if (atClient == null && atClientGenerator == null) { + if (atClient == null && atClientGenerator == null) { throw StateError('atClient and atClientGenerator are both null'); } @@ -145,7 +160,7 @@ class SSHNPDImpl implements SSHNPD { ..key = 'username.$device' ..sharedBy = deviceAtsign ..sharedWith = managerAtsign - ..namespace = SSHNPD.namespace + ..namespace = DefaultArgs.namespace ..metadata = metaData; try { @@ -165,9 +180,10 @@ class SSHNPDImpl implements SSHNPD { logger.info('Starting heartbeat'); startHeartbeat(); - logger.info('Subscribing to $device\\.${SSHNPD.namespace}@'); + logger.info('Subscribing to $device\\.${DefaultArgs.namespace}@'); notificationService - .subscribe(regex: '$device\\.${SSHNPD.namespace}@', shouldDecrypt: true) + .subscribe( + regex: '$device\\.${DefaultArgs.namespace}@', shouldDecrypt: true) .listen( _notificationHandler, onError: (e) => logger.severe('Notification Failed:$e'), @@ -221,7 +237,7 @@ class SSHNPDImpl implements SSHNPD { String notificationKey = notification.key .replaceAll('${notification.to}:', '') - .replaceAll('.$device.${SSHNPD.namespace}${notification.from}', '') + .replaceAll('.$device.${DefaultArgs.namespace}${notification.from}', '') // convert to lower case as the latest AtClient converts notification // keys to lower case when received .toLowerCase(); @@ -275,7 +291,7 @@ class SSHNPDImpl implements SSHNPD { ..key = 'heartbeat.$device' ..sharedBy = deviceAtsign ..sharedWith = notification.from - ..namespace = SSHNPD.namespace + ..namespace = DefaultArgs.namespace ..metadata = (Metadata() ..isPublic = false ..isEncrypted = true @@ -484,9 +500,8 @@ class SSHNPDImpl implements SSHNPD { try { // Connect to rendezvous point using background process. // This program can then exit without causing an issue. - Process rv = await SSHRV - .localBinary(host, port, localSshdPort: localSshdPort) - .run(); + Process rv = + await SSHRV.exec(host, port, localSshdPort: localSshdPort).run(); logger.info('Started rv - pid is ${rv.pid}'); /// - Generate an ephemeral keypair and adds its public key to the @@ -548,7 +563,7 @@ class SSHNPDImpl implements SSHNPD { String? errorMessage; switch (sshClient) { - case SupportedSshClient.hostSsh: + case SupportedSshClient.exec: (success, errorMessage) = await reverseSshViaExec( host: host, port: port, @@ -558,7 +573,7 @@ class SSHNPDImpl implements SSHNPD { requestingAtsign: requestingAtsign, privateKey: privateKey); break; - case SupportedSshClient.pureDart: + case SupportedSshClient.dart: (success, errorMessage) = await reverseSshViaSSHClient( host: host, port: port, @@ -606,7 +621,7 @@ class SSHNPDImpl implements SSHNPD { ..key = '$sessionId.$device' ..sharedBy = deviceAtsign ..sharedWith = requestingAtsign - ..namespace = SSHNPD.namespace + ..namespace = DefaultArgs.namespace ..metadata = (Metadata() ..isPublic = false ..isEncrypted = true @@ -854,7 +869,7 @@ class SSHNPDImpl implements SSHNPD { ..key = 'device_info.$device' ..sharedBy = deviceAtsign ..sharedWith = managerAtsign - ..namespace = SSHNPD.namespace + ..namespace = DefaultArgs.namespace ..metadata = metaData; if (!makeDeviceInfoVisible) { diff --git a/packages/noports_core/lib/sshnpd/sshnpd_params.dart b/packages/noports_core/lib/src/sshnpd/sshnpd_params.dart similarity index 90% rename from packages/noports_core/lib/sshnpd/sshnpd_params.dart rename to packages/noports_core/lib/src/sshnpd/sshnpd_params.dart index f2dd4b585..0e86dae2e 100644 --- a/packages/noports_core/lib/sshnpd/sshnpd_params.dart +++ b/packages/noports_core/lib/src/sshnpd/sshnpd_params.dart @@ -1,4 +1,7 @@ -part of 'sshnpd.dart'; +import 'package:args/args.dart'; +import 'package:noports_core/src/common/default_args.dart'; +import 'package:noports_core/src/common/supported_ssh_clients.dart'; +import 'package:noports_core/src/common/utils.dart'; class SSHNPDParams { late final String device; @@ -55,7 +58,7 @@ class SSHNPDParams { addSshPublicKeys = r['sshpublickey']; localSshdPort = - int.tryParse(r['local-sshd-port']) ?? defaults.defaultLocalSshdPort; + int.tryParse(r['local-sshd-port']) ?? DefaultArgs.localSshdPort; ephemeralPermissions = r['ephemeral-permissions']; @@ -117,7 +120,7 @@ class SSHNPDParams { parser.addOption('ssh-client', mandatory: false, - defaultsTo: SupportedSshClient.hostSsh.cliArg, + defaultsTo: SupportedSshClient.exec.cliArg, allowed: SupportedSshClient.values.map((c) => c.cliArg).toList(), help: 'What to use for outbound ssh connections.'); @@ -131,7 +134,7 @@ class SSHNPDParams { parser.addOption( 'local-sshd-port', help: 'port on which sshd is listening locally on localhost', - defaultsTo: defaults.defaultLocalSshdPort.toString(), + defaultsTo: DefaultArgs.localSshdPort.toString(), mandatory: false, ); @@ -146,7 +149,7 @@ class SSHNPDParams { parser.addFlag( 'rsa', abbr: 'r', - defaultsTo: defaults.defaultRsa, + defaultsTo: DefaultArgs.rsa, help: 'Use RSA 4096 keys rather than the default ED25519 keys', ); diff --git a/packages/noports_core/lib/sshrv/sshrv.dart b/packages/noports_core/lib/src/sshrv/sshrv.dart similarity index 79% rename from packages/noports_core/lib/sshrv/sshrv.dart rename to packages/noports_core/lib/src/sshrv/sshrv.dart index 349f7435d..11c7f0f15 100644 --- a/packages/noports_core/lib/sshrv/sshrv.dart +++ b/packages/noports_core/lib/src/sshrv/sshrv.dart @@ -1,12 +1,8 @@ import 'dart:io'; -import 'package:at_utils/at_utils.dart'; -import 'package:meta/meta.dart'; +import 'package:noports_core/src/sshrv/sshrv_impl.dart'; import 'package:socket_connector/socket_connector.dart'; - -import 'package:noports_core/common/defaults.dart' as defaults; - -part 'sshrv_impl.dart'; +import 'package:noports_core/src/common/default_args.dart'; typedef SSHRVGenerator = SSHRV Function(String, int, {int localSshdPort}); @@ -24,24 +20,24 @@ abstract class SSHRV { Future run(); // Can't use factory functions since SSHRV contains a generic type - static SSHRV localBinary( + static SSHRV exec( String host, int streamingPort, { - int localSshdPort = defaults.defaultLocalSshdPort, + int localSshdPort = DefaultArgs.localSshdPort, }) { - return SSHRVImpl( + return SSHRVImplExec( host, streamingPort, localSshdPort: localSshdPort, ); } - static SSHRV pureDart( + static SSHRV dart( String host, int streamingPort, { int localSshdPort = 22, }) { - return SSHRVImplPureDart( + return SSHRVImplDart( host, streamingPort, localSshdPort: localSshdPort, diff --git a/packages/noports_core/lib/sshrv/sshrv_impl.dart b/packages/noports_core/lib/src/sshrv/sshrv_impl.dart similarity index 75% rename from packages/noports_core/lib/sshrv/sshrv_impl.dart rename to packages/noports_core/lib/src/sshrv/sshrv_impl.dart index 86a979358..72d219f75 100644 --- a/packages/noports_core/lib/sshrv/sshrv_impl.dart +++ b/packages/noports_core/lib/src/sshrv/sshrv_impl.dart @@ -1,7 +1,14 @@ -part of 'sshrv.dart'; +import 'dart:io'; + +import 'package:at_utils/at_utils.dart'; +import 'package:meta/meta.dart'; +import 'package:noports_core/sshrv.dart'; +import 'package:socket_connector/socket_connector.dart'; + +import 'package:noports_core/src/common/default_args.dart'; @visibleForTesting -class SSHRVImpl implements SSHRV { +class SSHRVImplExec implements SSHRV { @override final String host; @@ -11,10 +18,10 @@ class SSHRVImpl implements SSHRV { @override final int localSshdPort; - const SSHRVImpl( + const SSHRVImplExec( this.host, this.streamingPort, { - this.localSshdPort = defaults.defaultLocalSshdPort, + this.localSshdPort = DefaultArgs.localSshdPort, }); @override @@ -36,7 +43,7 @@ class SSHRVImpl implements SSHRV { } @visibleForTesting -class SSHRVImplPureDart implements SSHRV { +class SSHRVImplDart implements SSHRV { @override final String host; @@ -46,7 +53,7 @@ class SSHRVImplPureDart implements SSHRV { @override final int localSshdPort; - const SSHRVImplPureDart( + const SSHRVImplDart( this.host, this.streamingPort, { this.localSshdPort = 22, diff --git a/packages/noports_core/lib/sshrvd/socket_connector.dart b/packages/noports_core/lib/src/sshrvd/socket_connector.dart similarity index 100% rename from packages/noports_core/lib/sshrvd/socket_connector.dart rename to packages/noports_core/lib/src/sshrvd/socket_connector.dart diff --git a/packages/noports_core/lib/sshrvd/sshrvd.dart b/packages/noports_core/lib/src/sshrvd/sshrvd.dart similarity index 84% rename from packages/noports_core/lib/sshrvd/sshrvd.dart rename to packages/noports_core/lib/src/sshrvd/sshrvd.dart index 1a12747d1..a88cdc596 100644 --- a/packages/noports_core/lib/sshrvd/sshrvd.dart +++ b/packages/noports_core/lib/src/sshrvd/sshrvd.dart @@ -1,18 +1,10 @@ import 'dart:async'; -import 'dart:io'; -import 'dart:isolate'; -import 'package:args/args.dart'; import 'package:at_client/at_client.dart'; import 'package:at_utils/at_logger.dart'; -import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; - -import 'package:noports_core/common/utils.dart'; -import 'package:noports_core/sshrvd/socket_connector.dart'; - -part 'sshrvd_impl.dart'; -part 'sshrvd_params.dart'; +import 'package:noports_core/src/sshrvd/sshrvd_impl.dart'; +import 'package:noports_core/src/sshrvd/sshrvd_params.dart'; abstract class SSHRVD { static const String namespace = 'sshrvd'; diff --git a/packages/noports_core/lib/sshrvd/sshrvd_impl.dart b/packages/noports_core/lib/src/sshrvd/sshrvd_impl.dart similarity index 91% rename from packages/noports_core/lib/sshrvd/sshrvd_impl.dart rename to packages/noports_core/lib/src/sshrvd/sshrvd_impl.dart index 576ee247c..f21470cc9 100644 --- a/packages/noports_core/lib/sshrvd/sshrvd_impl.dart +++ b/packages/noports_core/lib/src/sshrvd/sshrvd_impl.dart @@ -1,6 +1,17 @@ -part of 'sshrvd.dart'; - -@visibleForTesting +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:at_client/at_client.dart'; +import 'package:at_utils/at_logger.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:noports_core/src/common/utils.dart'; +import 'package:noports_core/src/sshrvd/socket_connector.dart'; +import 'package:noports_core/src/sshrvd/sshrvd.dart'; +import 'package:noports_core/src/sshrvd/sshrvd_params.dart'; + +@protected class SSHRVDImpl implements SSHRVD { @override final AtSignLogger logger = AtSignLogger(' sshrvd '); diff --git a/packages/noports_core/lib/sshrvd/sshrvd_params.dart b/packages/noports_core/lib/src/sshrvd/sshrvd_params.dart similarity index 96% rename from packages/noports_core/lib/sshrvd/sshrvd_params.dart rename to packages/noports_core/lib/src/sshrvd/sshrvd_params.dart index 8d1d13776..b98f919b1 100644 --- a/packages/noports_core/lib/sshrvd/sshrvd_params.dart +++ b/packages/noports_core/lib/src/sshrvd/sshrvd_params.dart @@ -1,4 +1,5 @@ -part of 'sshrvd.dart'; +import 'package:args/args.dart'; +import 'package:noports_core/src/common/utils.dart'; class SSHRVDParams { late final String username; diff --git a/packages/noports_core/lib/sshnp.dart b/packages/noports_core/lib/sshnp.dart new file mode 100644 index 000000000..3aba9d0ed --- /dev/null +++ b/packages/noports_core/lib/sshnp.dart @@ -0,0 +1,6 @@ +library noports_core_sshnp; + +export 'src/sshnp/sshnp.dart'; +export 'src/sshnp/sshnp_result.dart'; +export 'src/sshnp/sshnp_params/sshnp_params.dart'; +export 'src/common/supported_ssh_clients.dart'; diff --git a/packages/noports_core/lib/sshnp/sshnp.dart b/packages/noports_core/lib/sshnp/sshnp.dart deleted file mode 100644 index d34c87db1..000000000 --- a/packages/noports_core/lib/sshnp/sshnp.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:at_client/at_client.dart' hide StringBuffer; -import 'package:at_commons/at_builders.dart'; -import 'package:at_utils/at_logger.dart'; -import 'package:at_utils/at_utils.dart'; -import 'package:dartssh2/dartssh2.dart'; -import 'package:logging/logging.dart'; -import 'package:meta/meta.dart'; -import 'package:path/path.dart' as path; -import 'package:noports_core/common/supported_ssh_clients.dart'; -import 'package:noports_core/common/utils.dart'; -import 'package:noports_core/sshnp/config_repository/config_file_repository.dart'; -import 'package:noports_core/sshnp/sshnp_arg.dart'; -import 'package:noports_core/sshnp/utils.dart'; -import 'package:noports_core/sshnpd/sshnpd.dart'; -import 'package:noports_core/sshrv/sshrv.dart'; -import 'package:noports_core/sshrvd/sshrvd.dart'; -import 'package:uuid/uuid.dart'; - -import 'package:noports_core/common/defaults.dart' as defaults; - -part 'sshnp_impl.dart'; -part 'sshnp_params.dart'; -part 'sshnp_result.dart'; - -abstract class SSHNP { - abstract final AtSignLogger logger; - - // ==================================================================== - // Final instance variables, injected via constructor - // ==================================================================== - /// The [AtClient] used to communicate with sshnpd and sshrvd - abstract final AtClient atClient; - - /// The atSign of the sshnpd we wish to communicate with - abstract final String sshnpdAtSign; - - /// The device name of the sshnpd we wish to communicate with - abstract final String device; - - /// The user name on this host - abstract final String username; - - /// The home directory on this host - abstract final String homeDirectory; - - /// The sessionId we will use - abstract final String sessionId; - - /// The name of the public key file from ~/.ssh which the client may request - /// be appended to authorized_hosts on the remote device. Note that if the - /// daemon on the remote device is not running with the `-s` flag, then it - /// ignores such requests. - abstract final String publicKeyFileName; - - abstract final List localSshOptions; - - /// When false, we generate [sshPublicKey] and [sshPrivateKey] using ed25519. - /// When true, we generate [sshPublicKey] and [sshPrivateKey] using RSA. - /// Defaults to false - abstract 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. - abstract 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. - abstract int port; - - /// Port to which sshnpd will forwardRemote its [SSHClient]. If localPort - /// is set to '0' then - abstract int localPort; - - /// Port that local sshd is listening on localhost interface - /// Default set to [defaultLocalSshdPort] - abstract int localSshdPort; - - /// Port that the remote sshd is listening on localhost interface - /// Default set to [defaultRemoteSshdPort] - abstract int remoteSshdPort; - - // ==================================================================== - // Derived final instance variables, set during construction or init - // ==================================================================== - - /// Set to [AtClient.getCurrentAtSign] during construction - @visibleForTesting - abstract 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] - abstract 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 - abstract 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 - abstract final String sshPrivateKey; - - /// Namespace will be set to [device].sshnp - abstract final String namespace; - - /// When using sshrvd, this is fetched from sshrvd during [init] - int get sshrvdPort; - - /// Set by constructor to - /// '$homeDirectory${Platform.pathSeparator}.ssh${Platform.pathSeparator}' - abstract final String sshHomeDirectory; - - /// Function used to generate a [SSHRV] instance ([SSHRV.localBinary] by default) - abstract final SSHRV Function(String, int) sshrvGenerator; - - /// true once we have received any response (success or error) from sshnpd - @visibleForTesting - abstract bool sshnpdAck; - - /// true once we have received an error response from sshnpd - @visibleForTesting - abstract bool sshnpdAckErrors; - - /// true once we have received a response from sshrvd - @visibleForTesting - abstract bool sshrvdAck; - - abstract final bool legacyDaemon; - - bool verbose = false; - - /// true once [init] has completed - bool initialized = false; - - abstract final bool direct; - - /// If ssh tunnel is unused (no active connections via port forwards) for - /// longer than this many seconds, then the connection will be closed. - /// Defaults to [defaults.defaultIdleTimeout] - abstract int idleTimeout; - - /// The ssh client to use when doing outbound ssh within this program - abstract SupportedSshClient sshClient; - - /// When true, any local forwarding directives included in [localSshOptions] - /// will be added to the initial tunnel ssh request - abstract bool addForwardsToTunnel; - - /// Completes when the SSHNP instance is no longer doing anything - /// e.g. controlling a direct ssh tunnel using the pure-dart SSHClient - Future get done; - - /// Default parameters for sshnp - static const defaultDevice = 'default'; - static const defaultPort = 22; - static const defaultLocalPort = 0; - static const defaultSendSshPublicKey = ''; - static const defaultLocalSshOptions = []; - static const defaultLegacyDaemon = true; - static const defaultListDevices = false; - static const defaultSshClient = SupportedSshClient.hostSsh; - - factory SSHNP({ - // final fields - required AtClient atClient, - required String sshnpdAtSign, - required String device, - required String username, - required String homeDirectory, - required String sessionId, - String sendSshPublicKey = SSHNP.defaultSendSshPublicKey, - required List localSshOptions, - bool rsa = false, - // volatile fields - required String host, - required int port, - required int localPort, - String? remoteUsername, - bool verbose = false, - SSHRVGenerator sshrvGenerator = defaults.defaultSshrvGenerator, - int localSshdPort = defaults.defaultLocalSshdPort, - bool legacyDaemon = defaultLegacyDaemon, - int remoteSshdPort = defaults.defaultRemoteSshdPort, - int idleTimeout = defaults.defaultIdleTimeout, - required SupportedSshClient sshClient, - required bool addForwardsToTunnel, - }) { - return SSHNPImpl( - atClient: atClient, - sshnpdAtSign: sshnpdAtSign, - device: device, - username: username, - homeDirectory: homeDirectory, - sessionId: sessionId, - sendSshPublicKey: sendSshPublicKey, - localSshOptions: localSshOptions, - rsa: rsa, - host: host, - port: port, - localPort: localPort, - remoteUsername: remoteUsername, - verbose: verbose, - sshrvGenerator: sshrvGenerator, - localSshdPort: localSshdPort, - legacyDaemon: legacyDaemon, - remoteSshdPort: remoteSshdPort, - idleTimeout: idleTimeout, - sshClient: sshClient, - addForwardsToTunnel: addForwardsToTunnel, - ); - } - - static Future fromParams( - SSHNPParams p, { - AtClient? atClient, - FutureOr Function(SSHNPParams, String)? atClientGenerator, - void Function(Object, StackTrace)? usageCallback, - SSHRVGenerator sshrvGenerator = SSHRV.localBinary, - }) { - return SSHNPImpl.fromParams( - p, - atClient: atClient, - atClientGenerator: atClientGenerator, - usageCallback: usageCallback, - sshrvGenerator: sshrvGenerator, - ); - } - - /// 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(); - - /// 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(); - - /// Send a ping out to all sshnpd and listen for heartbeats - /// Returns two Iterable: - /// - Iterable of atSigns of sshnpd that responded - /// - Iterable of atSigns of sshnpd that did not respond - Future<(Iterable, Iterable, Map)> - listDevices(); -} diff --git a/packages/noports_core/lib/sshnp/sshnp_impl.dart b/packages/noports_core/lib/sshnp/sshnp_impl.dart deleted file mode 100644 index 95f46dcf1..000000000 --- a/packages/noports_core/lib/sshnp/sshnp_impl.dart +++ /dev/null @@ -1,1098 +0,0 @@ -part of 'sshnp.dart'; - -@visibleForTesting -class SSHNPImpl implements SSHNP { - @override - final AtSignLogger logger = AtSignLogger(' sshnp '); - - // ==================================================================== - // Final instance variables, injected via constructor - // ==================================================================== - /// The [AtClient] used to communicate with sshnpd and sshrvd - @override - final AtClient atClient; - - /// The atSign of the sshnpd we wish to communicate with - @override - final String sshnpdAtSign; - - /// The device name of the sshnpd we wish to communicate with - @override - final String device; - - /// The user name on this host - @override - final String username; - - /// The home directory on this host - @override - final String homeDirectory; - - /// The sessionId we will use - @override - final String sessionId; - - @override - late final String publicKeyFileName; - - @override - final List localSshOptions; - - @override - late final int localSshdPort; - - @override - late final int remoteSshdPort; - - /// When false, we generate [sshPublicKey] and [sshPrivateKey] using ed25519. - /// When true, we generate [sshPublicKey] and [sshPrivateKey] using RSA. - /// Defaults to false - @override - 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. - @override - 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. - @override - int port; - - /// Port to which sshnpd will forwardRemote its [SSHClient]. If localPort - /// is set to '0' then - @override - int localPort; - - // ==================================================================== - // Derived final instance variables, set during construction or init - // ==================================================================== - - /// Set to [AtClient.getCurrentAtSign] during construction - @override - @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] - @override - String? remoteUsername; - - /// Set by [generateSshKeys] during [init], if we're not doing direct ssh. - /// 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 - @override - 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 - @override - late final String sshPrivateKey; - - /// Namespace will be set to [device].sshnp - @override - late final String namespace; - - /// When using sshrvd, this is fetched from sshrvd during [init] - /// This is only set when using sshrvd - /// (i.e. after [getHostAndPortFromSshrvd] has been called) - @override - int get sshrvdPort => _sshrvdPort; - - late int _sshrvdPort; - - /// Set by constructor to - /// '$homeDirectory${Platform.pathSeparator}.ssh${Platform.pathSeparator}' - @override - late final String sshHomeDirectory; - - /// Function used to generate a [SSHRV] instance ([SSHRV.localbinary] by default) - @override - SSHRVGenerator sshrvGenerator; - - /// true once we have received any response (success or error) from sshnpd - @override - @visibleForTesting - bool sshnpdAck = false; - - /// true once we have received an error response from sshnpd - @override - @visibleForTesting - bool sshnpdAckErrors = false; - - @visibleForTesting - late String ephemeralPrivateKey; - - /// true once we have received a response from sshrvd - @override - @visibleForTesting - bool sshrvdAck = false; - - /// true once [init] has completed - @override - bool initialized = false; - - @override - bool verbose = false; - - @override - late final bool legacyDaemon; - - @override - late final bool direct; - - @override - late final SupportedSshClient sshClient; - - @override - late final int idleTimeout; - - @override - late final bool addForwardsToTunnel; - - final _doneCompleter = Completer(); - - @override - Future get done => _doneCompleter.future; - - SSHNPImpl({ - required this.atClient, - required this.sshnpdAtSign, - required this.device, - required this.username, - required this.homeDirectory, - required this.sessionId, - String sendSshPublicKey = SSHNP.defaultSendSshPublicKey, - required this.localSshOptions, - this.rsa = defaults.defaultRsa, - required this.host, - required this.port, - required this.localPort, - this.remoteUsername, - this.verbose = defaults.defaultVerbose, - this.sshrvGenerator = defaults.defaultSshrvGenerator, - this.localSshdPort = defaults.defaultLocalSshdPort, - this.legacyDaemon = SSHNP.defaultLegacyDaemon, - this.remoteSshdPort = defaults.defaultRemoteSshdPort, - this.idleTimeout = defaults.defaultIdleTimeout, - required this.sshClient, - this.addForwardsToTunnel = false, - }) { - namespace = '$device.sshnp'; - clientAtSign = atClient.getCurrentAtSign()!; - logger.hierarchicalLoggingEnabled = true; - logger.logger.level = Level.SHOUT; - - sshHomeDirectory = getDefaultSshDirectory(homeDirectory); - if (!Directory(sshHomeDirectory).existsSync()) { - try { - Directory(sshHomeDirectory).createSync(); - } catch (e, s) { - throw SSHNPFailed( - 'Unable to create ssh home directory $sshHomeDirectory\n' - 'hint: try manually creating $sshHomeDirectory and re-running sshnp', - e, - s, - ); - } - } - - // previously, the default value for sendSshPublicKey was 'false' instead of '' - // immediately set it to '' to avoid the program from attempting to - // search for a public key file called 'false' - if (sendSshPublicKey == 'false' || sendSshPublicKey.isEmpty) { - publicKeyFileName = ''; - } else if (path.normalize(sendSshPublicKey).contains('/') || - path.normalize(sendSshPublicKey).contains(r'\')) { - publicKeyFileName = path.normalize(path.absolute(sendSshPublicKey)); - } else { - publicKeyFileName = path.normalize('$sshHomeDirectory/$sendSshPublicKey'); - } - } - - static Future fromParams( - SSHNPParams p, { - AtClient? atClient, - FutureOr Function(SSHNPParams, String)? atClientGenerator, - void Function(Object, StackTrace)? usageCallback, - SSHRVGenerator sshrvGenerator = SSHRV.localBinary, - }) async { - try { - if (p.clientAtSign == null) { - throw ArgumentError('Option from is mandatory.'); - } - if (p.sshnpdAtSign == null) { - throw ArgumentError('Option to is mandatory.'); - } - - if (p.host == null) { - throw ArgumentError('Option host is mandatory.'); - } - - if (atClient == null && atClientGenerator == null) { - throw StateError('atClient adn atClientGenerator are both null'); - } - - String sessionId = Uuid().v4(); - atClient ??= await atClientGenerator!(p, sessionId); - - if (p.clientAtSign != atClient.getCurrentAtSign()) { - throw ArgumentError( - 'Option from must match the current atSign of the AtClient'); - } - - // Check to see if the port number is in range for TCP ports - if (p.localSshdPort > 65535 || p.localSshdPort < 1) { - throw ArgumentError( - '\nInvalid port number for sshd (1-65535) : ${p.localSshdPort}'); - } - - AtSignLogger.root_level = 'SHOUT'; - if (p.verbose) { - AtSignLogger.root_level = 'INFO'; - } - - 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, - verbose: p.verbose, - sshrvGenerator: sshrvGenerator, - localSshdPort: p.localSshdPort, - legacyDaemon: p.legacyDaemon, - remoteSshdPort: p.remoteSshdPort, - idleTimeout: p.idleTimeout, - sshClient: SupportedSshClient.values - .firstWhere((c) => c.cliArg == p.sshClient), - addForwardsToTunnel: p.addForwardsToTunnel, - ); - if (p.verbose) { - sshnp.logger.logger.level = Level.INFO; - } - - return sshnp; - } catch (e, s) { - usageCallback?.call(e, s); - if (e is SSHNPFailed) { - rethrow; - } - throw SSHNPFailed('Unknown failure:\n$e', e, s); - } - } - - /// 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] - @override - Future init() async { - if (initialized) { - throw StateError('Cannot init() - already initialized'); - } - - // determine the ssh direction - direct = useDirectSsh(legacyDaemon, host); - try { - if (!(await atSignIsActivated(atClient, sshnpdAtSign))) { - throw ('Device address $sshnpdAtSign is not activated.'); - } - } catch (e, s) { - throw SSHNPFailed( - 'Device address $sshnpdAtSign does not exist or is not activated.', - e, - s); - } - - 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); - - if (publicKeyFileName.isNotEmpty && !File(publicKeyFileName).existsSync()) { - throw ('Unable to find ssh public key file : $publicKeyFileName'); - } - - if (publicKeyFileName.isNotEmpty && - !File(publicKeyFileName.replaceAll('.pub', '')).existsSync()) { - throw ('Unable to find matching ssh private key for public key : $publicKeyFileName'); - } - - remoteUsername ?? await fetchRemoteUserName(); - - // find a spare local port - if (localPort == 0) { - try { - ServerSocket serverSocket = - await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - localPort = serverSocket.port; - await serverSocket.close(); - } catch (e, s) { - throw SSHNPFailed('Unable to find a spare local port', e, s); - } - } - - await sharePublicKeyWithSshnpdIfRequired(); - - // If host has an @ then contact the sshrvd service for some ports - if (host.startsWith('@')) { - await getHostAndPortFromSshrvd(); - } - - // If we're doing reverse (i.e. not direct) then we need to - // 1) generate some ephemeral keys for the daemon to use to ssh back to us - // 2) if legacy then we share the private key via its own notification - if (!direct) { - try { - var (String ephemeralPublicKey, String ephemeralPrivateKey) = - await generateSshKeys( - rsa: rsa, - sessionId: sessionId, - sshHomeDirectory: sshHomeDirectory); - - sshPublicKey = ephemeralPublicKey; - sshPrivateKey = ephemeralPrivateKey; - } catch (e, s) { - throw SSHNPFailed('Failed to generate ephemeral keypair', e, s); - } - - try { - await addEphemeralKeyToAuthorizedKeys( - sshPublicKey: sshPublicKey, - localSshdPort: localSshdPort, - sessionId: sessionId); - } catch (e, s) { - throw SSHNPFailed( - 'Failed to add ephemeral key to authorized_keys', e, s); - } - - if (legacyDaemon) { - await sharePrivateKeyWithSshnpd(); - } - } - - 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 - @override - Future run() async { - if (!initialized) { - return SSHNPFailed('Cannot run() - not initialized'); - } - - late SSHNPResult res; - if (legacyDaemon) { - logger.info('Requesting legacy daemon to start reverse ssh session'); - res = await legacyStartReverseSsh(); - _doneCompleter.complete(); - } else { - if (direct) { - // Note that when direct, this client is initiating the tunnel ssh. - // - // If tunnel is created using /usr/bin/ssh then it is exec'd in the - // background, and the `directSshViaExec` method will call - // _doneCompleter.complete() before it returns. - // - // However if tunnel is created using pure dart SSHClient then the - // tunnel is being managed by the SSHNP instance. In that case, - // _doneCompleter.complete() is called once the tunnel determines - // that there are no more active connections. - logger.info( - 'Requesting daemon to set up socket tunnel for direct ssh session'); - res = await startDirectSsh(); - } else { - logger.info('Requesting daemon to start reverse ssh session'); - res = await startReverseSsh(); - _doneCompleter.complete(); - } - } - - return res; - } - - Future startDirectSsh() async { - // send request to the daemon via notification - await _notify( - AtKey() - ..key = 'ssh_request' - ..namespace = namespace - ..sharedBy = clientAtSign - ..sharedWith = sshnpdAtSign - ..metadata = (Metadata() - ..ttr = -1 - ..ttl = 10000), - signAndWrapAndJsonEncode(atClient, { - 'direct': true, - 'sessionId': sessionId, - 'host': host, - 'port': port - }), - sessionId: sessionId); - - bool acked = await waitForDaemonResponse(); - if (!acked) { - return SSHNPFailed( - 'sshnp timed out: waiting for daemon response\nhint: make sure the device is online'); - } - - if (sshnpdAckErrors) { - return SSHNPFailed('sshnp failed: with sshnpd acknowledgement errors'); - } - // 1) Execute an ssh command setting up local port forwarding. - // Note that this is very similar to what the daemon does when we - // ask for a reverse ssh - logger.info( - 'Starting direct ssh session for $username to $host on port $_sshrvdPort with forwardLocal of $localPort'); - - try { - bool success = false; - String? errorMessage; - Process? process; - SSHClient? client; - switch (sshClient) { - case SupportedSshClient.hostSsh: - (success, errorMessage, process) = await directSshViaExec(); - _doneCompleter.complete(); - break; - case SupportedSshClient.pureDart: - (success, errorMessage, client) = await directSshViaSSHClient(); - break; - } - - if (!success) { - errorMessage ??= - 'Failed to start ssh tunnel and / or forward local port $localPort'; - return SSHNPFailed(errorMessage); - } - // All good - write the ssh command to stdout - return SSHCommand.base( - localPort: localPort, - remoteUsername: remoteUsername, - host: 'localhost', - privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), - localSshOptions: (addForwardsToTunnel) ? null : localSshOptions, - sshProcess: process, - sshClient: client, - ); - } catch (e, s) { - return SSHNPFailed('SSH Client failure : $e', e, s); - } - } - - Future<(bool, String?, SSHClient?)> directSshViaSSHClient() async { - late final SSHSocket socket; - try { - socket = await SSHSocket.connect(host, _sshrvdPort); - } catch (e) { - return (false, 'Failed to open socket to $host:$port : $e', null); - } - - late final SSHClient client; - try { - client = SSHClient( - socket, - username: remoteUsername!, - identities: [ - // A single private key file may contain multiple keys. - ...SSHKeyPair.fromPem(ephemeralPrivateKey) - ], - keepAliveInterval: Duration(seconds: 15), - ); - } catch (e) { - return ( - false, - 'Failed to create SSHClient for $username@$host:$port : $e', - null - ); - } - - try { - await client.authenticated; - } catch (e) { - return ( - false, - 'Failed to authenticate as $username@$host:$port : $e', - null - ); - } - - int counter = 0; - - Future startForwarding( - {required int fLocalPort, - required String fRemoteHost, - required int fRemotePort}) async { - logger.info('Starting port forwarding' - ' from port $fLocalPort on localhost' - ' to $fRemoteHost:$fRemotePort on remote side'); - - /// Do the port forwarding for sshd - final serverSocket = await ServerSocket.bind('localhost', fLocalPort); - - serverSocket.listen((socket) async { - counter++; - final forward = await client.forwardLocal(fRemoteHost, fRemotePort); - unawaited( - forward.stream.cast>().pipe(socket).whenComplete( - () async { - counter--; - }, - ), - ); - unawaited(socket.pipe(forward.sink)); - }, onError: (Object error) { - counter = 0; - }, onDone: () { - counter = 0; - }); - } - - // Start local forwarding to the remote sshd - await startForwarding( - fLocalPort: localPort, - fRemoteHost: 'localhost', - fRemotePort: remoteSshdPort); - - if (addForwardsToTunnel) { - var optionsSplitBySpace = localSshOptions.join(' ').split(' '); - logger.info('addForwardsToTunnel is true;' - ' localSshOptions split by space is $optionsSplitBySpace'); - // parse the localSshOptions, extract all of the local port forwarding - // directives and act on all of them - var lsoIter = optionsSplitBySpace.iterator; - while (lsoIter.moveNext()) { - if (lsoIter.current == '-L') { - // we expect the args next - bool hasArgs = lsoIter.moveNext(); - if (!hasArgs) { - logger.warning('localSshOptions has -L with no args'); - continue; - } - String argString = lsoIter.current; - // We expect args like $localPort:$remoteHost:$remotePort - List args = argString.split(':'); - if (args.length != 3) { - logger.warning('localSshOptions has -L with bad args $argString'); - continue; - } - int? fLocalPort = int.tryParse(args[0]); - String fRemoteHost = args[1]; - int? fRemotePort = int.tryParse(args[2]); - if (fLocalPort == null || - fRemoteHost.isEmpty || - fRemotePort == null) { - logger.warning('localSshOptions has -L with bad args $argString'); - continue; - } - - // Start the forwarding - await startForwarding( - fLocalPort: fLocalPort, - fRemoteHost: fRemoteHost, - fRemotePort: fRemotePort); - } - } - } - - /// Set up timer to check to see if all connections are down - logger.info('ssh session will terminate after $idleTimeout seconds' - ' if it is not being used'); - Timer.periodic(Duration(seconds: idleTimeout), (timer) async { - if (counter == 0) { - timer.cancel(); - client.close(); - await client.done; - _doneCompleter.complete(); - logger.shout('$sessionId | no active connections' - ' - ssh session complete'); - } - }); - - return (true, null, client); - } - - Future<(bool, String?, Process?)> directSshViaExec() async { - // If using exec then we can assume we're on something unix-y - // So we can write the ephemeralPrivateKey to a tmp file, - // set its permissions appropriately, and remove it after we've - // executed the command - var tmpFileName = - path.normalize('$sshHomeDirectory/tmp/ephemeral_$sessionId'); - File tmpFile = File(tmpFileName); - await tmpFile.create(recursive: true); - await tmpFile.writeAsString(ephemeralPrivateKey, - mode: FileMode.write, flush: true); - await Process.run('chmod', ['go-rwx', tmpFileName]); - - String argsString = '$remoteUsername@$host' - ' -p $_sshrvdPort' - ' -i $tmpFileName' - ' -L $localPort:localhost:$remoteSshdPort' - ' -o LogLevel=VERBOSE' - ' -t -t' - ' -o StrictHostKeyChecking=accept-new' - ' -o IdentitiesOnly=yes' - ' -o BatchMode=yes' - ' -o ExitOnForwardFailure=yes' - ' -f' // fork after authentication - this is important - ; - if (addForwardsToTunnel) { - argsString += ' ${localSshOptions.join(' ')}'; - } - argsString += ' sleep 15'; - - List args = argsString.split(' '); - - logger.info('$sessionId | Executing /usr/bin/ssh ${args.join(' ')}'); - - // Because of the options we are using, we can wait for this process - // to complete, because it will exit with exitCode 0 once it has connected - // successfully - late int sshExitCode; - final soutBuf = StringBuffer(); - final serrBuf = StringBuffer(); - Process? process; - try { - process = await Process.start('/usr/bin/ssh', args); - process.stdout.transform(Utf8Decoder()).listen((String s) { - soutBuf.write(s); - logger.info('$sessionId | sshStdOut | $s'); - }, onError: (e) {}); - process.stderr.transform(Utf8Decoder()).listen((String s) { - serrBuf.write(s); - logger.info('$sessionId | sshStdErr | $s'); - }, onError: (e) {}); - sshExitCode = await process.exitCode.timeout(Duration(seconds: 10)); - // ignore: unused_catch_clause - } on TimeoutException catch (e) { - sshExitCode = 6464; - } - - await tmpFile.delete(); - - String? errorMessage; - if (sshExitCode != 0) { - if (sshExitCode == 6464) { - logger.shout( - '$sessionId | Command timed out: /usr/bin/ssh ${args.join(' ')}'); - errorMessage = 'Failed to establish connection - timed out'; - } else { - logger.shout('$sessionId | Exit code $sshExitCode from' - ' /usr/bin/ssh ${args.join(' ')}'); - errorMessage = - 'Failed to establish connection - exit code $sshExitCode'; - } - } - - return (sshExitCode == 0, errorMessage, process); - } - - /// Identical to [legacyStartReverseSsh] except for the request notification - Future startReverseSsh() async { - // Connect to rendezvous point using background process. - // sshnp (this program) can then exit without issue. - SSHRV sshrv = - sshrvGenerator(host, _sshrvdPort, localSshdPort: localSshdPort); - Future sshrvResult = sshrv.run(); - - // send request to the daemon via notification - await _notify( - AtKey() - ..key = 'ssh_request' - ..namespace = namespace - ..sharedBy = clientAtSign - ..sharedWith = sshnpdAtSign - ..metadata = (Metadata() - ..ttr = -1 - ..ttl = 10000), - signAndWrapAndJsonEncode(atClient, { - 'direct': false, - 'sessionId': sessionId, - 'host': host, - 'port': port, - 'username': username, - 'remoteForwardPort': localPort, - 'privateKey': sshPrivateKey - }), - sessionId: sessionId); - - bool acked = await waitForDaemonResponse(); - await cleanUpAfterReverseSsh(this); - if (!acked) { - return SSHNPFailed( - 'sshnp connection timeout: waiting for daemon response'); - } - - if (sshnpdAckErrors) { - return SSHNPFailed('sshnp failed: with sshnpd acknowledgement errors'); - } - - return SSHCommand.base( - localPort: localPort, - remoteUsername: remoteUsername, - host: 'localhost', - privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), - localSshOptions: (addForwardsToTunnel) ? localSshOptions : null, - sshrvResult: sshrvResult, - ); - } - - Future legacyStartReverseSsh() async { - // Connect to rendezvous point using background process. - // sshnp (this program) can then exit without issue. - SSHRV sshrv = - sshrvGenerator(host, _sshrvdPort, localSshdPort: localSshdPort); - Future sshrvResult = sshrv.run(); - - // send request to the daemon via notification - await _notify( - AtKey() - ..key = 'sshd' - ..namespace = namespace - ..sharedBy = clientAtSign - ..sharedWith = sshnpdAtSign - ..metadata = (Metadata() - ..ttr = -1 - ..ttl = 10000), - '$localPort $port $username $host $sessionId', - sessionId: sessionId); - - bool acked = await waitForDaemonResponse(); - await cleanUpAfterReverseSsh(this); - if (!acked) { - return SSHNPFailed( - 'sshnp timed out: waiting for daemon response\nhint: make sure the device is online'); - } - - if (sshnpdAckErrors) { - return SSHNPFailed('sshnp failed: with sshnpd acknowledgement errors'); - } - - return SSHCommand.base( - localPort: localPort, - remoteUsername: remoteUsername, - host: 'localhost', - privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), - localSshOptions: (addForwardsToTunnel) ? null : localSshOptions, - sshrvResult: sshrvResult, - ); - } - - /// 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'); - - bool connected = false; - - if (notification.value == 'connected') { - connected = true; - } else if (notification.value.startsWith('{')) { - late final Map envelope; - late final Map daemonResponse; - try { - envelope = jsonDecode(notification.value!); - assertValidValue(envelope, 'signature', String); - assertValidValue(envelope, 'hashingAlgo', String); - assertValidValue(envelope, 'signingAlgo', String); - - daemonResponse = envelope['payload'] as Map; - assertValidValue(daemonResponse, 'sessionId', String); - assertValidValue(daemonResponse, 'ephemeralPrivateKey', String); - } catch (e) { - logger.warning( - 'Failed to extract parameters from notification value "${notification.value}" with error : $e'); - sshnpdAck = true; - sshnpdAckErrors = true; - return; - } - - try { - await verifyEnvelopeSignature(atClient, sshnpdAtSign, logger, envelope); - } catch (e) { - logger.shout('Failed to verify signature of msg from $sshnpdAtSign'); - logger.shout('Exception: $e'); - logger.shout('Notification value: ${notification.value}'); - sshnpdAck = true; - sshnpdAckErrors = true; - return; - } - - ephemeralPrivateKey = daemonResponse['ephemeralPrivateKey']; - connected = true; - } - - if (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, s) { - stderr.writeln("Device \"$device\" unknown, or username not shared"); - await cleanUpAfterReverseSsh(this); - throw SSHNPFailed( - "Device unknown, or username not shared\n" - "hint: make sure the device shares username or set remote username manually", - e, - s); - } - } - - Future sharePublicKeyWithSshnpdIfRequired() async { - if (publicKeyFileName.isEmpty) return; - - try { - String toSshPublicKey = await File(publicKeyFileName).readAsString(); - if (!toSshPublicKey.startsWith('ssh-')) { - throw ('$publicKeyFileName does not look like a public key file'); - } - AtKey sendOurPublicKeyToSshnpd = AtKey() - ..key = 'sshpublickey' - ..sharedBy = clientAtSign - ..sharedWith = sshnpdAtSign - ..metadata = (Metadata() - ..ttr = -1 - ..ttl = 10000); - await _notify(sendOurPublicKeyToSshnpd, toSshPublicKey); - } catch (e, s) { - stderr.writeln( - "Error opening or validating public key file or sending to remote atSign: $e"); - await cleanUpAfterReverseSsh(this); - throw SSHNPFailed( - 'Error opening or validating public key file or sending to remote atSign', - e, - s); - } - } - - Future sharePrivateKeyWithSshnpd() async { - AtKey sendOurPrivateKeyToSshnpd = AtKey() - ..key = 'privatekey' - ..sharedBy = clientAtSign - ..sharedWith = sshnpdAtSign - ..namespace = namespace - ..metadata = (Metadata() - ..ttr = -1 - ..ttl = 10000); - await _notify(sendOurPrivateKeyToSshnpd, sshPrivateKey); - } - - Future getHostAndPortFromSshrvd() async { - atClient.notificationService - .subscribe( - regex: '$sessionId.${SSHRVD.namespace}@', shouldDecrypt: true) - .listen((notification) async { - String ipPorts = notification.value.toString(); - List results = ipPorts.split(','); - host = results[0]; - port = int.parse(results[1]); - _sshrvdPort = int.parse(results[2]); - sshrvdAck = true; - }); - - AtKey ourSshrvdIdKey = AtKey() - ..key = '$device.${SSHRVD.namespace}' - ..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); - await _notify(ourSshrvdIdKey, sessionId); - - int counter = 0; - while (!sshrvdAck) { - await Future.delayed(Duration(milliseconds: 100)); - counter++; - if (counter == 100) { - await cleanUpAfterReverseSsh(this); - stderr.writeln('sshnp: connection timeout to sshrvd $host service'); - throw ('Connection timeout to sshrvd $host service\nhint: make sure host is valid and online'); - } - } - } - - Future> _getAtKeysRemote( - {String? regex, - String? sharedBy, - String? sharedWith, - bool showHiddenKeys = false}) async { - var builder = ScanVerbBuilder() - ..sharedWith = sharedWith - ..sharedBy = sharedBy - ..regex = regex - ..showHiddenKeys = showHiddenKeys - ..auth = true; - var scanResult = await atClient.getRemoteSecondary()?.executeVerb(builder); - scanResult = scanResult?.replaceFirst('data:', '') ?? ''; - var result = []; - if (scanResult.isNotEmpty) { - result = List.from(jsonDecode(scanResult)).map((key) { - try { - return AtKey.fromString(key); - } on InvalidSyntaxException { - logger.severe('$key is not a well-formed key'); - } on Exception catch (e) { - logger.severe( - 'Exception occurred: ${e.toString()}. Unable to form key $key'); - } - }).toList(); - } - result.removeWhere((element) => element == null); - return result.cast(); - } - - @override - Future<(Iterable, Iterable, Map)> - listDevices() async { - // get all the keys device_info.*.sshnpd - var scanRegex = 'device_info\\.$asciiMatcher\\.${SSHNPD.namespace}'; - - var atKeys = - await _getAtKeysRemote(regex: scanRegex, sharedBy: sshnpdAtSign); - - var devices = {}; - var heartbeats = {}; - var info = {}; - - // Listen for heartbeat notifications - atClient.notificationService - .subscribe(regex: 'heartbeat\\.$asciiMatcher', shouldDecrypt: true) - .listen((notification) { - var deviceInfo = jsonDecode(notification.value ?? '{}'); - var devicename = deviceInfo['devicename']; - if (devicename != null) { - heartbeats.add(devicename); - } - }); - - // for each key, get the value - for (var entryKey in atKeys) { - var atValue = await atClient.get( - entryKey, - getRequestOptions: GetRequestOptions()..bypassCache = true, - ); - var deviceInfo = jsonDecode(atValue.value) ?? {}; - - if (deviceInfo['devicename'] == null) { - continue; - } - - var devicename = deviceInfo['devicename'] as String; - info[devicename] = deviceInfo; - - var metaData = Metadata() - ..isPublic = false - ..isEncrypted = true - ..ttr = -1 - ..namespaceAware = true; - - var pingKey = AtKey() - ..key = "ping.$devicename" - ..sharedBy = clientAtSign - ..sharedWith = entryKey.sharedBy - ..namespace = SSHNPD.namespace - ..metadata = metaData; - - unawaited(_notify(pingKey, 'ping')); - - // Add the device to the base list - devices.add(devicename); - } - - // wait for 10 seconds in case any are being slow - await Future.delayed(const Duration(seconds: 5)); - - // The intersection is in place on the off chance that some random device - // sends a heartbeat notification, but is not on the list of devices - return ( - devices.intersection(heartbeats), - devices.difference(heartbeats), - info, - ); - } - - /// This function sends a notification given an atKey and value - Future _notify(AtKey atKey, String value, - {String sessionId = ""}) async { - await atClient.notificationService - .notify(NotificationParams.forUpdate(atKey, value: value), - onSuccess: (notification) { - logger.info('SUCCESS:$notification for: $sessionId with value: $value'); - }, onError: (notification) { - logger.info('ERROR:$notification'); - }); - } - - Future waitForDaemonResponse() async { - 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) { - return false; - } - } - return true; - } -} diff --git a/packages/noports_core/lib/sshnp/sshnp_result.dart b/packages/noports_core/lib/sshnp/sshnp_result.dart deleted file mode 100644 index 51f48d97d..000000000 --- a/packages/noports_core/lib/sshnp/sshnp_result.dart +++ /dev/null @@ -1,74 +0,0 @@ -part of 'sshnp.dart'; - -abstract class SSHNPResult {} - -abstract class SSHNPCommandResult implements SSHNPResult { - String get command; - List get args; -} - -const _optionsWithPrivateKey = ['-o StrictHostKeyChecking=accept-new', '-o IdentitiesOnly=yes']; - -class SSHNPFailed implements SSHNPResult { - final String message; - final Object? exception; - final StackTrace? stackTrace; - - SSHNPFailed(this.message, [this.exception, this.stackTrace]); - - @override - String toString() { - return message; - } -} - -class SSHCommand implements SSHNPCommandResult { - @override - final String command = 'ssh'; - - final int localPort; - final String? remoteUsername; - final String host; - final String? privateKeyFileName; - - final List sshOptions; - - Future? sshrvResult; - Process? sshProcess; - SSHClient? sshClient; - - SSHCommand.base({ - required this.localPort, - required this.remoteUsername, - required this.host, - List? localSshOptions, - this.privateKeyFileName, - this.sshrvResult, - this.sshProcess, - this.sshClient, - }) : sshOptions = [ - if (shouldIncludePrivateKey(privateKeyFileName)) ..._optionsWithPrivateKey, - ...(localSshOptions ?? []) - ]; - - static bool shouldIncludePrivateKey(String? privateKeyFileName) => - privateKeyFileName != null && privateKeyFileName.isNotEmpty; - - @override - List get args => [ - '-p $localPort', - ...sshOptions, - if (remoteUsername != null) '$remoteUsername@$host', - if (remoteUsername == null) host, - if (shouldIncludePrivateKey(privateKeyFileName)) ...['-i', '$privateKeyFileName'], - ]; - - @override - String toString() { - final sb = StringBuffer(); - sb.write(command); - sb.write(' '); - sb.write(args.join(' ')); - return sb.toString(); - } -} diff --git a/packages/noports_core/lib/sshnp_params.dart b/packages/noports_core/lib/sshnp_params.dart new file mode 100644 index 000000000..536bb4f72 --- /dev/null +++ b/packages/noports_core/lib/sshnp_params.dart @@ -0,0 +1,7 @@ +library noports_core_sshnp_params; + +export 'src/sshnp/sshnp_params/config_file_repository.dart'; +export 'src/sshnp/sshnp_params/config_key_repository.dart'; +export 'src/sshnp/sshnp_params/sshnp_params.dart'; +export 'src/sshnp/sshnp_params/sshnp_arg.dart'; +export 'src/common/supported_ssh_clients.dart'; \ No newline at end of file diff --git a/packages/noports_core/lib/sshnpd.dart b/packages/noports_core/lib/sshnpd.dart new file mode 100644 index 000000000..340d9641b --- /dev/null +++ b/packages/noports_core/lib/sshnpd.dart @@ -0,0 +1,4 @@ +library noports_core_sshnpd; + +export 'src/sshnpd/sshnpd.dart'; +export 'src/sshnpd/sshnpd_params.dart'; \ No newline at end of file diff --git a/packages/noports_core/lib/sshrv.dart b/packages/noports_core/lib/sshrv.dart new file mode 100644 index 000000000..3a2b323b9 --- /dev/null +++ b/packages/noports_core/lib/sshrv.dart @@ -0,0 +1,3 @@ +library noports_core_sshrv; + +export 'src/sshrv/sshrv.dart'; \ No newline at end of file diff --git a/packages/noports_core/lib/sshrvd.dart b/packages/noports_core/lib/sshrvd.dart new file mode 100644 index 000000000..d31136bbb --- /dev/null +++ b/packages/noports_core/lib/sshrvd.dart @@ -0,0 +1,4 @@ +library noports_core_sshrvd; + +export 'src/sshrvd/sshrvd.dart'; +export 'src/sshrvd/sshrvd_params.dart'; diff --git a/packages/noports_core/lib/utils.dart b/packages/noports_core/lib/utils.dart new file mode 100644 index 000000000..6050058bb --- /dev/null +++ b/packages/noports_core/lib/utils.dart @@ -0,0 +1,5 @@ +library noports_core_utils; + +export 'src/common/utils.dart'; +export 'src/sshnp/utils.dart'; +export 'src/common/default_args.dart'; diff --git a/packages/noports_core/lib/version.dart b/packages/noports_core/lib/version.dart index 5216dc737..4cbfe8270 100644 --- a/packages/noports_core/lib/version.dart +++ b/packages/noports_core/lib/version.dart @@ -1,2 +1,2 @@ // Note: if you update this version also update pubspec.yaml -const String version = "4.0.0-dev.1"; +const String version = "4.0.0"; diff --git a/packages/noports_core/pubspec.yaml b/packages/noports_core/pubspec.yaml index bb2ced39d..77d323ac5 100644 --- a/packages/noports_core/pubspec.yaml +++ b/packages/noports_core/pubspec.yaml @@ -3,7 +3,7 @@ description: Core library code for sshnoports # NOTE: If you update the version number here, you # must also update it in version.dart -version: 4.0.0-dev.1 +version: 4.0.0 homepage: https://docs.atsign.com/ diff --git a/packages/noports_core/test/sshnp_test.dart b/packages/noports_core/test/sshnp_test.dart index 2c45313c9..37607cc1a 100644 --- a/packages/noports_core/test/sshnp_test.dart +++ b/packages/noports_core/test/sshnp_test.dart @@ -1,6 +1,6 @@ import 'package:args/args.dart'; -import 'package:noports_core/common/utils.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; +import 'package:noports_core/src/common/utils.dart'; +import 'package:noports_core/src/sshnp/sshnp_params/sshnp_params.dart'; import 'package:test/test.dart'; void main() { @@ -46,7 +46,7 @@ void main() { expect(p.username, getUserName(throwIfNull: true)); expect(p.homeDirectory, getHomeDirectory(throwIfNull: true)); expect(p.atKeysFilePath, - getDefaultAtKeysFilePath(p.homeDirectory, p.clientAtSign ?? '')); + getDefaultAtKeysFilePath(p.homeDirectory, p.clientAtSign)); expect(p.sendSshPublicKey, ''); expect(p.localSshOptions, []); expect(p.rsa, false); diff --git a/packages/noports_core/test/sshnpd_test.dart b/packages/noports_core/test/sshnpd_test.dart index d717ff14d..8d6e22bfd 100644 --- a/packages/noports_core/test/sshnpd_test.dart +++ b/packages/noports_core/test/sshnpd_test.dart @@ -1,8 +1,8 @@ -import 'package:noports_core/common/supported_ssh_clients.dart'; -import 'package:noports_core/sshnpd/sshnpd.dart'; +import 'package:noports_core/src/common/supported_ssh_clients.dart'; +import 'package:noports_core/sshnpd.dart'; import 'package:test/test.dart'; import 'package:args/args.dart'; -import 'package:noports_core/common/utils.dart'; +import 'package:noports_core/src/common/utils.dart'; void main() { group('args parser test', () { @@ -40,19 +40,19 @@ void main() { test('test --ssh-client arg', () { expect(SSHNPDParams.fromArgs('-a @bob -m @alice'.split(' ')).sshClient, - SupportedSshClient.hostSsh); + SupportedSshClient.exec); expect( SSHNPDParams.fromArgs( '-a @bob -m @alice --ssh-client pure-dart'.split(' ')) .sshClient, - SupportedSshClient.pureDart); + SupportedSshClient.dart); expect( SSHNPDParams.fromArgs( '-a @bob -m @alice --ssh-client /usr/bin/ssh'.split(' ')) .sshClient, - SupportedSshClient.hostSsh); + SupportedSshClient.exec); expect( () => SSHNPDParams.fromArgs( diff --git a/packages/sshnoports/bin/sshnp.dart b/packages/sshnoports/bin/sshnp.dart index 1181f9216..7b371eee8 100644 --- a/packages/sshnoports/bin/sshnp.dart +++ b/packages/sshnoports/bin/sshnp.dart @@ -6,102 +6,144 @@ import 'dart:io'; import 'package:at_utils/at_logger.dart'; // local packages -import 'package:noports_core/sshnp/sshnp.dart'; -import 'package:noports_core/sshnp/utils.dart'; +import 'package:noports_core/sshnp.dart'; +import 'package:noports_core/sshnp_params.dart' show SSHNPArg; import 'package:sshnoports/create_at_client_cli.dart'; import 'package:sshnoports/version.dart'; void main(List args) async { AtSignLogger.root_level = 'SHOUT'; AtSignLogger.defaultLoggingHandler = AtSignLogger.stdErrLoggingHandler; - late final SSHNP sshnp; + late final SSHNPParams params; + SSHNP? sshnp; - try { - params = SSHNPParams.fromPartial(SSHNPPartialParams.fromArgs(args)); - } catch (error) { - stderr.writeln(error.toString()); - exit(1); - } + // Manually check if the verbose flag is set + Set verboseSet = SSHNPArg.fromName('verbose').aliasList.toSet(); + final bool verbose = args.toSet().intersection(verboseSet).isNotEmpty; - try { - sshnp = await SSHNP.fromParams( - params, - atClientGenerator: (SSHNPParams params, String sessionId) => - createAtClientCli( - homeDirectory: params.homeDirectory, - atsign: params.clientAtSign!, - namespace: '${params.device}.sshnp', - pathExtension: sessionId, - atKeysFilePath: params.atKeysFilePath, - rootDomain: params.rootDomain, - ), - usageCallback: (e, s) { - printVersion(); - stdout.writeln(SSHNPPartialParams.parser.usage); - stderr.writeln('\n$e'); - }, - ); - } on ArgumentError catch (_) { - exit(1); + // Manually check if the help flag is set + Set helpSet = SSHNPArg.fromName('help').aliasList.toSet(); + final bool help = args.toSet().intersection(helpSet).isNotEmpty; + + if (help) { + printVersion(); + stderr.writeln(SSHNPPartialParams.parser.usage); + exit(0); } ProcessSignal.sigint.watch().listen((signal) async { - await cleanUpAfterReverseSsh(sshnp); + await sshnp?.cleanUp(); exit(1); }); await runZonedGuarded(() async { - if (params.listDevices) { - stdout.writeln('Searching for devices...'); - var (active, off, info) = await sshnp.listDevices(); - if (active.isEmpty && off.isEmpty) { - stdout.writeln('[X] No devices found\n'); - stdout.writeln( - 'Note: only devices with sshnpd version 3.4.0 or higher are supported by this command.'); - stdout.writeln( - 'Please update your devices to sshnpd version >= 3.4.0 and try again.'); + try { + params = SSHNPParams.fromPartial(SSHNPPartialParams.fromArgs(args)); + sshnp = await SSHNP + .fromParams( + params, + atClientGenerator: (SSHNPParams params, String sessionId) => + createAtClientCli( + homeDirectory: params.homeDirectory, + atsign: params.clientAtSign, + namespace: '${params.device}.sshnp', + pathExtension: sessionId, + atKeysFilePath: params.atKeysFilePath, + rootDomain: params.rootDomain, + ), + ) + .catchError((e) { + if (e.stackTrace != null) { + Error.throwWithStackTrace(e, e.stackTrace!); + } + throw e; + }); + + if (params.listDevices) { + stderr.writeln('Searching for devices...'); + var (active, off, info) = await sshnp!.listDevices(); + printDevices(active, off, info); exit(0); } - stdout.writeln('Active Devices:'); - _printDevices(active, info); - stdout.writeln('Inactive Devices:'); - _printDevices(off, info); - exit(0); - } + await sshnp!.initialized; - await sshnp.init(); - SSHNPResult res = await sshnp.run(); - if (res is SSHNPFailed) { - stderr.write('$res\n'); + SSHNPResult res = await sshnp!.run(); + + if (res is SSHNPError) { + if (res.stackTrace != null) { + Error.throwWithStackTrace(res, res.stackTrace!); + } + throw res; + } + if (res is SSHNPCommand) { + stdout.write('$res\n'); + await sshnp!.done; + exit(0); + } + if (res is SSHNPNoOpSuccess) { + stderr.write('$res\n'); + await sshnp!.done; + exit(0); + } + } on ArgumentError catch (error, stackTrace) { + usageCallback(error, stackTrace); + exit(1); + } on SSHNPError catch (error, stackTrace) { + stderr.writeln(error.toString()); + if (verbose) { + stderr.writeln('\nStack Trace: ${stackTrace.toString()}'); + } + + await sshnp?.cleanUp(); exit(1); - } - if (res is SSHCommand) { - stdout.write('$res\n'); - await sshnp.done; - exit(0); } }, (Object error, StackTrace stackTrace) async { - stderr.writeln(error.toString()); - - if (params.verbose) { + if (error is ArgumentError) return; + if (error is SSHNPError) return; + stderr.writeln('Unknown error: ${error.toString()}'); + if (verbose) { stderr.writeln('\nStack Trace: ${stackTrace.toString()}'); } - await cleanUpAfterReverseSsh(sshnp); - - await stderr.flush().timeout(Duration(milliseconds: 100)); + await sshnp?.cleanUp(); exit(1); }); } -void _printDevices(Iterable devices, Map info) { +void usageCallback(Object e, StackTrace s) { + printVersion(); + stderr.writeln(SSHNPPartialParams.parser.usage); + stderr.writeln('\n$e'); +} + +void printDevices( + Iterable active, + Iterable off, + Map info, +) { + if (active.isEmpty && off.isEmpty) { + stderr.writeln('[X] No devices found\n'); + stderr.writeln( + 'Note: only devices with sshnpd version 3.4.0 or higher are supported by this command.'); + stderr.writeln( + 'Please update your devices to sshnpd version >= 3.4.0 and try again.'); + exit(0); + } + + stderr.writeln('Active Devices:'); + printDeviceList(active, info); + stderr.writeln('Inactive Devices:'); + printDeviceList(off, info); +} + +void printDeviceList(Iterable devices, Map info) { if (devices.isEmpty) { - stdout.writeln(' [X] No devices found'); + stderr.writeln(' No devices found'); return; } for (var device in devices) { - stdout.writeln(' $device - v${info[device]?['version']}'); + stderr.writeln(' $device - v${info[device]?['version']}'); } } diff --git a/packages/sshnoports/bin/sshnpd.dart b/packages/sshnoports/bin/sshnpd.dart index 44398911b..83f1ad273 100644 --- a/packages/sshnoports/bin/sshnpd.dart +++ b/packages/sshnoports/bin/sshnpd.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'package:at_utils/at_logger.dart'; -import 'package:noports_core/sshnpd/sshnpd.dart'; +import 'package:noports_core/sshnpd.dart'; import 'package:sshnoports/create_at_client_cli.dart'; import 'package:sshnoports/version.dart'; diff --git a/packages/sshnoports/bin/sshrv.dart b/packages/sshnoports/bin/sshrv.dart index 6edfe776a..54caf639a 100644 --- a/packages/sshnoports/bin/sshrv.dart +++ b/packages/sshnoports/bin/sshrv.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:noports_core/sshrv/sshrv.dart'; +import 'package:noports_core/sshrv.dart'; Future main(List args) async { if (args.length < 2 || args.length > 3) { @@ -17,5 +17,5 @@ Future main(List args) async { localSshdPort = int.parse(args[2]); } - await SSHRV.pureDart(host, streamingPort, localSshdPort: localSshdPort).run(); + await SSHRV.dart(host, streamingPort, localSshdPort: localSshdPort).run(); } diff --git a/packages/sshnoports/bin/sshrvd.dart b/packages/sshnoports/bin/sshrvd.dart index 2213abfc6..4d067d76c 100644 --- a/packages/sshnoports/bin/sshrvd.dart +++ b/packages/sshnoports/bin/sshrvd.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'package:at_utils/at_logger.dart'; -import 'package:noports_core/sshrvd/sshrvd.dart'; +import 'package:noports_core/sshrvd.dart'; import 'package:sshnoports/create_at_client_cli.dart'; import 'package:sshnoports/version.dart'; diff --git a/packages/sshnoports/lib/create_at_client_cli.dart b/packages/sshnoports/lib/create_at_client_cli.dart index a46afb7c3..38dce2240 100644 --- a/packages/sshnoports/lib/create_at_client_cli.dart +++ b/packages/sshnoports/lib/create_at_client_cli.dart @@ -1,9 +1,10 @@ import 'dart:io'; import 'package:at_client/at_client.dart'; import 'package:at_onboarding_cli/at_onboarding_cli.dart'; +import 'package:noports_core/utils.dart'; import 'package:version/version.dart'; import 'package:path/path.dart' as path; -import 'package:noports_core/common/service_factories.dart'; +import 'service_factories.dart'; Future createAtClientCli({ required String homeDirectory, @@ -11,8 +12,8 @@ Future createAtClientCli({ required String atKeysFilePath, String? pathExtension, String subDirectory = '.sshnp', - String namespace = 'sshnp', - String rootDomain = 'root.atsign.org', + String namespace = DefaultArgs.namespace, + String rootDomain = DefaultArgs.rootDomain, }) async { // Now on to the atPlatform startup //onboarding preference builder can be used to set onboardingService parameters @@ -31,8 +32,9 @@ Future createAtClientCli({ ..atProtocolEmitted = Version(2, 0, 0) ..rootDomain = rootDomain; - AtOnboardingService onboardingService = - AtOnboardingServiceImpl(atsign, atOnboardingConfig, atServiceFactory: ServiceFactoryWithNoOpSyncService()); + AtOnboardingService onboardingService = AtOnboardingServiceImpl( + atsign, atOnboardingConfig, + atServiceFactory: ServiceFactoryWithNoOpSyncService()); await onboardingService.authenticate(); diff --git a/packages/noports_core/lib/common/service_factories.dart b/packages/sshnoports/lib/service_factories.dart similarity index 100% rename from packages/noports_core/lib/common/service_factories.dart rename to packages/sshnoports/lib/service_factories.dart diff --git a/packages/sshnoports/lib/version.dart b/packages/sshnoports/lib/version.dart index 85d0da462..75dcb4575 100644 --- a/packages/sshnoports/lib/version.dart +++ b/packages/sshnoports/lib/version.dart @@ -1,7 +1,7 @@ import 'dart:io'; // Note: if you update this version also update pubspec.yaml -const String version = "4.0.0-rc.6"; +const String version = "4.0.0-rc.7"; /// Print version number void printVersion() { diff --git a/packages/sshnoports/pubspec.lock b/packages/sshnoports/pubspec.lock index fda4a0f7b..1399626eb 100644 --- a/packages/sshnoports/pubspec.lock +++ b/packages/sshnoports/pubspec.lock @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: coverage - sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + sha256: "595a29b55ce82d53398e1bcc2cba525d7bd7c59faeb2d2540e9d42c390cfeeeb" url: "https://pub.dev" source: hosted - version: "1.6.3" + version: "1.6.4" cron: dependency: transitive description: @@ -444,11 +444,10 @@ packages: noports_core: dependency: "direct main" description: - name: noports_core - sha256: b5f31f7457a5b34063b15feb6e17dd41a5da9427ea01fcd2fed7ee6de57035aa - url: "https://pub.dev" - source: hosted - version: "4.0.0-dev.1" + path: "../noports_core" + relative: true + source: path + version: "4.0.0" package_config: dependency: transitive description: @@ -685,10 +684,10 @@ packages: dependency: transitive description: name: vm_service - sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + sha256: a13d5503b4facefc515c8c587ce3cf69577a7b064a9f1220e005449cf1f64aad url: "https://pub.dev" source: hosted - version: "11.10.0" + version: "12.0.0" watcher: dependency: transitive description: diff --git a/packages/sshnoports/pubspec.yaml b/packages/sshnoports/pubspec.yaml index 43cbc121e..960378af9 100644 --- a/packages/sshnoports/pubspec.yaml +++ b/packages/sshnoports/pubspec.yaml @@ -3,13 +3,13 @@ publish_to: none # NOTE: If you update the version number here, you # must also update it in version.dart -version: 4.0.0-rc.6 +version: 4.0.0-rc.7 environment: sdk: ">=3.0.0 <4.0.0" dependencies: - noports_core: 4.0.0-dev.1 + noports_core: 4.0.0 at_onboarding_cli: 1.3.0 dependency_overrides: diff --git a/packages/sshnoports/templates/docker/Dockerfile b/packages/sshnoports/templates/docker/Dockerfile index 1ed307b88..cf61b9d2a 100644 --- a/packages/sshnoports/templates/docker/Dockerfile +++ b/packages/sshnoports/templates/docker/Dockerfile @@ -1,4 +1,5 @@ FROM dart:3.1.3@sha256:97cc20588eb7171f611606fff26bc04fb2aec5e68f7341060252a409bf7a86ce AS buildimage +ENV PACKAGEDIR=packages/sshnoports ENV BINARYDIR=/usr/local/at SHELL ["/bin/bash", "-c"] WORKDIR /app @@ -6,10 +7,10 @@ COPY . . RUN \ set -eux ; \ mkdir -p ${BINARYDIR} ; \ - dart pub get ; \ - dart pub update ; \ - dart compile exe bin/sshnpd.dart -o ${BINARYDIR}/sshnpd ; \ - dart compile exe bin/sshrv.dart -o ${BINARYDIR}/sshrv + dart pub get -C ${PACKAGEDIR}; \ + dart pub update -C ${PACKAGEDIR}; \ + dart compile exe ${PACKAGEDIR}/bin/sshnpd.dart -o ${BINARYDIR}/sshnpd ; \ + dart compile exe ${PACKAGEDIR}/bin/sshrv.dart -o ${BINARYDIR}/sshrv # Second stage of build FROM debian-slim FROM debian:stable-20230919-slim@sha256:149e944a6f4855f9738baf4ddd79fc2f218e6440218223fa9017aebc1e45f1f5 @@ -19,7 +20,7 @@ ENV BINARYDIR=/usr/local/at ENV USER_ID=1024 ENV GROUP_ID=1024 -COPY --from=buildimage /app/templates/docker/.startup.sh ${HOMEDIR}/ +COPY --from=buildimage /app/packages/sshnoports/templates/docker/.startup.sh ${HOMEDIR}/ RUN \ set -eux ; \ apt-get update ; \ diff --git a/packages/sshnoports/templates/docker/Dockerfile.activate b/packages/sshnoports/templates/docker/Dockerfile.activate index f9e05c022..7bd83297f 100644 --- a/packages/sshnoports/templates/docker/Dockerfile.activate +++ b/packages/sshnoports/templates/docker/Dockerfile.activate @@ -1,4 +1,5 @@ FROM dart:3.1.3@sha256:97cc20588eb7171f611606fff26bc04fb2aec5e68f7341060252a409bf7a86ce AS buildimage +ENV PACKAGEDIR=packages/sshnoports ENV BINARYDIR=/usr/local/at SHELL ["/bin/bash", "-c"] WORKDIR /app @@ -6,9 +7,9 @@ COPY . . RUN \ set -eux ; \ mkdir -p ${BINARYDIR} ; \ - dart pub get ; \ - dart pub update ; \ - dart compile exe bin/activate_cli.dart -o ${BINARYDIR}/at_activate + dart pub get -C ${PACKAGEDIR}; \ + dart pub update -C ${PACKAGEDIR}; \ + dart compile exe ${PACKAGEDIR}/bin/activate_cli.dart -o ${BINARYDIR}/at_activate # Second stage of build FROM debian-slim FROM debian:stable-20230919-slim@sha256:149e944a6f4855f9738baf4ddd79fc2f218e6440218223fa9017aebc1e45f1f5 diff --git a/packages/sshnoports/templates/docker/docker-compose.local.yaml b/packages/sshnoports/templates/docker/docker-compose.local.yaml index b5192ded8..3cf20ef63 100644 --- a/packages/sshnoports/templates/docker/docker-compose.local.yaml +++ b/packages/sshnoports/templates/docker/docker-compose.local.yaml @@ -2,15 +2,15 @@ version: "3.0" services: activate: build: - context: ../../ - dockerfile: ./templates/docker/Dockerfile.activate + context: ../../../../ + dockerfile: ./packages/sshnoports/templates/docker/Dockerfile.activate volumes: - ${HOME}/.atsign/keys:/atsign/.atsign/keys command: -a "${TO}" -c "${TO_CRAM}" sshnpd: build: - context: ../../ - dockerfile: ./templates/docker/Dockerfile + context: ../../../../ + dockerfile: ./packages/sshnoports/templates/docker/Dockerfile volumes: - ${HOME}/.atsign/keys:/atsign/.atsign/keys command: -a "${TO}" -m "${FROM}" -d "${DEVICE}" -s -u -v diff --git a/packages/sshnp_gui/lib/src/controllers/config_controller.dart b/packages/sshnp_gui/lib/src/controllers/config_controller.dart index bd10b1c79..abb6838e2 100644 --- a/packages/sshnp_gui/lib/src/controllers/config_controller.dart +++ b/packages/sshnp_gui/lib/src/controllers/config_controller.dart @@ -3,24 +3,26 @@ import 'dart:async'; import 'package:at_client_mobile/at_client_mobile.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; -import 'package:noports_core/sshnp/config_repository/config_key_repository.dart'; +import 'package:noports_core/sshnp_params.dart'; import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; enum ConfigFileWriteState { create, update } /// A provider that exposes the [CurrentConfigController] to the app. -final currentConfigController = AutoDisposeNotifierProvider( +final currentConfigController = + AutoDisposeNotifierProvider( CurrentConfigController.new, ); /// A provider that exposes the [ConfigListController] to the app. -final configListController = AutoDisposeAsyncNotifierProvider>( +final configListController = + AutoDisposeAsyncNotifierProvider>( ConfigListController.new, ); /// A provider that exposes the [ConfigFamilyController] to the app. -final configFamilyController = AutoDisposeAsyncNotifierProviderFamily( +final configFamilyController = AutoDisposeAsyncNotifierProviderFamily< + ConfigFamilyController, SSHNPParams, String>( ConfigFamilyController.new, ); @@ -29,7 +31,8 @@ class CurrentConfigState { final String profileName; final ConfigFileWriteState configFileWriteState; - CurrentConfigState({required this.profileName, required this.configFileWriteState}); + CurrentConfigState( + {required this.profileName, required this.configFileWriteState}); } /// Controller for the current [SSHNPParams] being edited @@ -70,7 +73,8 @@ class ConfigListController extends AutoDisposeAsyncNotifier> { } /// Controller for the family of [SSHNPParams] controllers -class ConfigFamilyController extends AutoDisposeFamilyAsyncNotifier { +class ConfigFamilyController + extends AutoDisposeFamilyAsyncNotifier { @override Future build(String arg) async { AtClient atClient = AtClientManager.getInstance().atClient; @@ -83,11 +87,14 @@ class ConfigFamilyController extends AutoDisposeFamilyAsyncNotifier putConfig(SSHNPParams params, {String? oldProfileName, BuildContext? context}) async { + Future putConfig(SSHNPParams params, + {String? oldProfileName, BuildContext? context}) async { AtClient atClient = AtClientManager.getInstance().atClient; SSHNPParams oldParams = state.value ?? SSHNPParams.empty(); if (oldProfileName != null) { - ref.read(configFamilyController(oldProfileName).notifier).deleteConfig(context: context); + ref + .read(configFamilyController(oldProfileName).notifier) + .deleteConfig(context: context); } if (params.clientAtSign != atClient.getCurrentAtSign()) { params = SSHNPParams.merge( @@ -111,9 +118,11 @@ class ConfigFamilyController extends AutoDisposeFamilyAsyncNotifier deleteConfig({BuildContext? context}) async { try { - await ConfigKeyRepository.deleteParams(arg, atClient: AtClientManager.getInstance().atClient); + await ConfigKeyRepository.deleteParams(arg, + atClient: AtClientManager.getInstance().atClient); ref.read(configListController.notifier).remove(arg); - state = AsyncValue.error('SSHNPParams has been disposed', StackTrace.current); + state = + AsyncValue.error('SSHNPParams has been disposed', StackTrace.current); } catch (e) { if (context?.mounted ?? false) { CustomSnackBar.error(content: 'Failed to delete profile: $arg'); diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart index 87f5a50e8..139abba59 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart @@ -3,8 +3,7 @@ import 'dart:io'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:noports_core/sshnp/config_repository/config_file_repository.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; +import 'package:noports_core/sshnp_params.dart'; import 'package:sshnp_gui/src/controllers/config_controller.dart'; import 'package:sshnp_gui/src/presentation/widgets/home_screen_actions/home_screen_import_dialog.dart'; import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; @@ -15,32 +14,41 @@ class HomeScreenActionCallbacks { if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { return _importDesktop(ref, context); } - CustomSnackBar.error(content: 'Unable to import profile:\nUnsupported platform'); + CustomSnackBar.error( + content: 'Unable to import profile:\nUnsupported platform'); } - static Future _importDesktop(WidgetRef ref, BuildContext context) async { + static Future _importDesktop( + WidgetRef ref, BuildContext context) async { try { - final XFile? file = await openFile(acceptedTypeGroups: [dotEnvTypeGroup]); + final XFile? file = + await openFile(acceptedTypeGroups: [dotEnvTypeGroup]); if (file == null) return; if (context.mounted) { String initialName = ConfigFileRepository.toProfileName(file.path); - String? profileName = await _getProfileNameFromUser(context, initialName: initialName); + String? profileName = + await _getProfileNameFromUser(context, initialName: initialName); if (profileName == null) return; if (profileName.isEmpty) profileName = initialName; final lines = (await file.readAsString()).split('\n'); - ref.read(configFamilyController(profileName).notifier).putConfig(SSHNPParams.fromConfig(profileName, lines)); + ref + .read(configFamilyController(profileName).notifier) + .putConfig(SSHNPParams.fromConfig(profileName, lines)); } } catch (e) { - CustomSnackBar.error(content: 'Unable to import profile:\n${e.toString()}'); + CustomSnackBar.error( + content: 'Unable to import profile:\n${e.toString()}'); } } - static Future _getProfileNameFromUser(BuildContext context, {String? initialName}) async { + static Future _getProfileNameFromUser(BuildContext context, + {String? initialName}) async { String? profileName; setProfileName(String? p) => profileName = p; await showDialog( context: context, - builder: (_) => HomeScreenImportDialog(setProfileName, initialName: initialName), + builder: (_) => + HomeScreenImportDialog(setProfileName, initialName: initialName), ); return profileName; } diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_callbacks.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_callbacks.dart index 17c8287b7..e81bec965 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_callbacks.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_callbacks.dart @@ -5,8 +5,8 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:noports_core/common/utils.dart'; -import 'package:noports_core/sshnp/config_repository/config_file_repository.dart'; +import 'package:noports_core/utils.dart'; +import 'package:noports_core/sshnp_params.dart'; import 'package:sshnp_gui/src/controllers/config_controller.dart'; import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_delete_dialog.dart'; @@ -36,17 +36,22 @@ class ProfileActionCallbacks { ); } - static Future export(WidgetRef ref, BuildContext context, String profileName) async { + static Future export( + WidgetRef ref, BuildContext context, String profileName) async { if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { return _exportDesktop(ref, context, profileName); } - CustomSnackBar.error(content: 'Unable to export profile:\nUnsupported platform'); + CustomSnackBar.error( + content: 'Unable to export profile:\nUnsupported platform'); } - static Future _exportDesktop(WidgetRef ref, BuildContext context, String profileName) async { + static Future _exportDesktop( + WidgetRef ref, BuildContext context, String profileName) async { try { - final suggestedName = ConfigFileRepository.fromProfileName(profileName, basenameOnly: true); - final initialDirectory = getDefaultSshnpConfigDirectory(getHomeDirectory(throwIfNull: true)!); + final suggestedName = + ConfigFileRepository.fromProfileName(profileName, basenameOnly: true); + final initialDirectory = + getDefaultSshnpConfigDirectory(getHomeDirectory(throwIfNull: true)!); final FileSaveLocation? saveLocation = await getSaveLocation( suggestedName: suggestedName, @@ -55,7 +60,8 @@ class ProfileActionCallbacks { ); if (saveLocation == null) return; final params = ref.read(configFamilyController(profileName)); - final fileData = Uint8List.fromList(params.requireValue.toConfig().codeUnits); + final fileData = + Uint8List.fromList(params.requireValue.toConfig().codeUnits); final XFile textFile = XFile.fromData( fileData, mimeType: dotEnvMimeType, @@ -64,7 +70,8 @@ class ProfileActionCallbacks { await textFile.saveTo(saveLocation.path); } catch (e) { - CustomSnackBar.error(content: 'Unable to export profile:\n${e.toString()}'); + CustomSnackBar.error( + content: 'Unable to export profile:\n${e.toString()}'); } } } diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_run_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_run_action.dart index 631d7d55d..9cf277f8f 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_run_action.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_run_action.dart @@ -1,11 +1,8 @@ -import 'dart:io'; - import 'package:at_client_mobile/at_client_mobile.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:socket_connector/socket_connector.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; -import 'package:noports_core/sshrv/sshrv.dart'; +import 'package:noports_core/sshnp.dart'; +import 'package:noports_core/sshrv.dart'; import 'package:sshnp_gui/src/controllers/background_session_controller.dart'; import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_action_button.dart'; import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; @@ -28,7 +25,10 @@ class _ProfileRunActionState extends ConsumerState { } Future onStart() async { - ref.read(backgroundSessionFamilyController(widget.params.profileName!).notifier).start(); + ref + .read(backgroundSessionFamilyController(widget.params.profileName!) + .notifier) + .start(); try { SSHNPParams params = SSHNPParams.merge( widget.params, @@ -43,16 +43,19 @@ class _ProfileRunActionState extends ConsumerState { sshnp = await SSHNP.fromParams( params, atClient: AtClientManager.getInstance().atClient, - sshrvGenerator: SSHRV.pureDart, + sshrvGenerator: SSHRV.dart, ); await sshnp!.init(); sshnpResult = await sshnp!.run(); - if (sshnpResult is SSHNPFailed) { + if (sshnpResult is SSHNPError) { throw sshnpResult!; } - ref.read(backgroundSessionFamilyController(widget.params.profileName!).notifier).endStartUp(); + ref + .read(backgroundSessionFamilyController(widget.params.profileName!) + .notifier) + .endStartUp(); } catch (e) { Future stop = onStop(); if (mounted) { @@ -63,17 +66,17 @@ class _ProfileRunActionState extends ConsumerState { } Future onStop() async { - if (sshnpResult is SSHCommand) { - (sshnpResult as SSHCommand).sshProcess?.kill(); // DirectSSHViaExec - (sshnpResult as SSHCommand).sshClient?.close(); // DirectSSHViaClient - var sshrvResult = await (sshnpResult as SSHCommand).sshrvResult; - if (sshrvResult is Process) sshrvResult.kill(); // SSHRV via local binary - if (sshrvResult is SocketConnector) sshrvResult.close(); // SSHRV via pure dart + if (sshnpResult is SSHNPCommand) { + await (sshnpResult as SSHNPCommand).killConnectionBean(); } - ref.read(backgroundSessionFamilyController(widget.params.profileName!).notifier).stop(); + ref + .read(backgroundSessionFamilyController(widget.params.profileName!) + .notifier) + .stop(); } - static Widget getIconFromStatus(BackgroundSessionStatus status, BuildContext context) { + static Widget getIconFromStatus( + BackgroundSessionStatus status, BuildContext context) { switch (status) { case BackgroundSessionStatus.stopped: return const Icon(Icons.play_arrow); @@ -90,7 +93,8 @@ class _ProfileRunActionState extends ConsumerState { @override Widget build(BuildContext context) { - final status = ref.watch(backgroundSessionFamilyController(widget.params.profileName!)); + final status = ref + .watch(backgroundSessionFamilyController(widget.params.profileName!)); return ProfileActionButton( onPressed: () async { switch (status) { diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_terminal_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_terminal_action.dart index d2a205f32..9e4ae3b0d 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_terminal_action.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_terminal_action.dart @@ -2,8 +2,8 @@ import 'package:at_client_mobile/at_client_mobile.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; -import 'package:noports_core/sshrv/sshrv.dart'; +import 'package:noports_core/sshnp.dart'; +import 'package:noports_core/sshrv.dart'; import 'package:sshnp_gui/src/controllers/navigation_rail_controller.dart'; import 'package:sshnp_gui/src/controllers/terminal_session_controller.dart'; import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_action_button.dart'; @@ -42,12 +42,12 @@ class _ProfileTerminalActionState extends ConsumerState { final sshnp = await SSHNP.fromParams( params, atClient: AtClientManager.getInstance().atClient, - sshrvGenerator: SSHRV.pureDart, + sshrvGenerator: SSHRV.dart, ); await sshnp.init(); final result = await sshnp.run(); - if (result is SSHNPFailed) { + if (result is SSHNPError) { throw result; } @@ -59,7 +59,7 @@ class _ProfileTerminalActionState extends ConsumerState { final sessionController = ref.watch(terminalSessionFamilyController(sessionId).notifier); - if (result is SSHNPCommandResult) { + if (result is SSHNPCommand) { /// Set the command for the new session sessionController.setProcess( command: result.command, args: result.args); diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_actions.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_actions.dart index 1c1ef8cf1..b8a66d1a2 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_actions.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_actions.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; +import 'package:noports_core/sshnp.dart'; import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_actions.dart'; class ProfileBarActions extends StatelessWidget { diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/profile_form.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/profile_form.dart index c33dfc4cd..70102fe2d 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/profile_form.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/profile_form.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:noports_core/sshnp/sshnp.dart'; +import 'package:noports_core/sshnp.dart'; import 'package:sshnp_gui/src/controllers/navigation_rail_controller.dart'; import 'package:sshnp_gui/src/controllers/config_controller.dart'; import 'package:sshnp_gui/src/presentation/widgets/profile_form/custom_text_form_field.dart'; @@ -102,7 +102,7 @@ class _ProfileFormState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ CustomTextFormField( - initialValue: oldConfig.sshnpdAtSign ?? '', + initialValue: oldConfig.sshnpdAtSign, labelText: strings.sshnpdAtSign, onChanged: (value) => newConfig = SSHNPPartialParams.merge( @@ -113,7 +113,7 @@ class _ProfileFormState extends ConsumerState { ), gapW8, CustomTextFormField( - initialValue: oldConfig.host ?? '', + initialValue: oldConfig.host, labelText: strings.host, onChanged: (value) => newConfig = SSHNPPartialParams.merge( diff --git a/packages/sshnp_gui/lib/src/repository/authentication_repository.dart b/packages/sshnp_gui/lib/src/repository/authentication_repository.dart index ce624757a..b5f2024ff 100644 --- a/packages/sshnp_gui/lib/src/repository/authentication_repository.dart +++ b/packages/sshnp_gui/lib/src/repository/authentication_repository.dart @@ -9,9 +9,9 @@ import 'package:at_utils/at_logger.dart' show AtSignLogger; import 'package:at_utils/at_utils.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:noports_core/utils.dart' show DefaultArgs; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:noports_core/sshnpd/sshnpd.dart'; import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; import 'package:sshnp_gui/src/repository/navigation_repository.dart'; @@ -57,7 +57,7 @@ class AuthenticationRepository { return AtClientPreference() ..rootDomain = AtEnv.rootDomain - ..namespace = SSHNPD.namespace + ..namespace = DefaultArgs.namespace ..hiveStoragePath = dir.path ..commitLogPath = dir.path ..isLocalStoreRequired = true; diff --git a/packages/sshnp_gui/pubspec.lock b/packages/sshnp_gui/pubspec.lock index b49ed54c0..d12df9205 100644 --- a/packages/sshnp_gui/pubspec.lock +++ b/packages/sshnp_gui/pubspec.lock @@ -763,7 +763,7 @@ packages: path: "../noports_core" relative: true source: path - version: "4.0.0-dev.1" + version: "4.0.0" package_config: dependency: transitive description: diff --git a/packages/sshnp_gui/pubspec.yaml b/packages/sshnp_gui/pubspec.yaml index 58b8856e8..6a3da490c 100644 --- a/packages/sshnp_gui/pubspec.yaml +++ b/packages/sshnp_gui/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: go_router: ^9.0.3 intl: any macos_ui: ^2.0.0 - noports_core: ^4.0.0-dev.1 + noports_core: ^4.0.0 page_transition: ^2.0.9 path: ^1.8.3 path_provider: ^2.0.11 diff --git a/pubspec.yaml b/pubspec.yaml index 2d502d539..85b94aa65 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,6 @@ name: sshnoports_workspace environment: sdk: ">=2.12.0 <4.0.0" - flutter: ">=1.20.0" dev_dependencies: melos: ^3.1.1 diff --git a/tests/end2end_tests/contexts/_init_/setup-sshnp-entrypoint.sh b/tests/end2end_tests/contexts/_init_/setup-sshnp-entrypoint.sh index b63bb48de..72ae98636 100755 --- a/tests/end2end_tests/contexts/_init_/setup-sshnp-entrypoint.sh +++ b/tests/end2end_tests/contexts/_init_/setup-sshnp-entrypoint.sh @@ -9,6 +9,7 @@ sshnp=$2 # e.g. @alice sshnpd=$3 # e.g. @alice sshrvd=$4 # e.g. @alice template_name=$5 # e.g. sshnp_entrypoint.sh +legacy=$6 # e.g. "arg1 arg2 arg3" cp ../../entrypoints/"$template_name" ../sshnp/entrypoint.sh # copy template to the mounted folder @@ -23,4 +24,11 @@ fi eval "$prefix" "s/@sshnpatsign/${sshnp}/g" ../sshnp/entrypoint.sh eval "$prefix" "s/@sshnpdatsign/${sshnpd}/g" ../sshnp/entrypoint.sh eval "$prefix" "s/@sshrvdatsign/${sshrvd}/g" ../sshnp/entrypoint.sh -eval "$prefix" "s/deviceName/${device}/g" ../sshnp/entrypoint.sh \ No newline at end of file +eval "$prefix" "s/deviceName/${device}/g" ../sshnp/entrypoint.sh +legacy_sub='' +if [ "$legacy" == 'true' ]; then + legacy_sub="s/legacy/--legacy-daemon/g" +else + legacy_sub='s/legacy//g' +fi +eval "$prefix" "$legacy_sub" ../sshnp/entrypoint.sh \ No newline at end of file diff --git a/tests/end2end_tests/entrypoints/sshnp_entrypoint.sh b/tests/end2end_tests/entrypoints/sshnp_entrypoint.sh index ad3bdbb9c..05384a880 100644 --- a/tests/end2end_tests/entrypoints/sshnp_entrypoint.sh +++ b/tests/end2end_tests/entrypoints/sshnp_entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/bash echo "SSHNP START ENTRY" -SSHNP_COMMAND="$HOME/.local/bin/sshnp -f @sshnpatsign -t @sshnpdatsign -d deviceName -h @sshrvdatsign -s id_ed25519.pub -v > sshnp.log" +SSHNP_COMMAND="$HOME/.local/bin/sshnp -f @sshnpatsign -t @sshnpdatsign -d deviceName -h @sshrvdatsign -s id_ed25519.pub -v legacy > sshnp.log" run_test() { diff --git a/tests/end2end_tests/entrypoints/sshnp_installer_entrypoint.sh b/tests/end2end_tests/entrypoints/sshnp_installer_entrypoint.sh index 97ea189b8..3211cb921 100644 --- a/tests/end2end_tests/entrypoints/sshnp_installer_entrypoint.sh +++ b/tests/end2end_tests/entrypoints/sshnp_installer_entrypoint.sh @@ -1,7 +1,5 @@ #!/bin/bash -sleep WAITING_TIME # time for sshnpd to share device name - -SSHNP_COMMAND="$HOME/.local/bin/sshnp -f @sshnpatsign -t @sshnpdatsign -d deviceName -h @sshrvdatsign -s id_ed25519.pub -v > sshnp.log" +SSHNP_COMMAND="$HOME/.local/bin/sshnp -f @sshnpatsign -t @sshnpdatsign -d deviceName -h @sshrvdatsign -s id_ed25519.pub -v legacy > sshnp.log" echo "Running: $SSHNP_COMMAND" eval "$SSHNP_COMMAND" cat sshnp.log diff --git a/tests/end2end_tests/image/Dockerfile b/tests/end2end_tests/image/Dockerfile index 8671970be..4873aaa7a 100644 --- a/tests/end2end_tests/image/Dockerfile +++ b/tests/end2end_tests/image/Dockerfile @@ -30,23 +30,26 @@ FROM dart:3.1.3@sha256:97cc20588eb7171f611606fff26bc04fb2aec5e68f7341060252a409b ENV URL=https://github.com/atsign-foundation/sshnoports.git ENV REPO_DIR=/app/repo +ENV PACKAGE_DIR=${REPO_DIR}/packages/sshnoports ENV OUTPUT_DIR=/app/output ARG branch=trunk +# Builds using the noports_core package available on the branch specified RUN set -eux ; \ mkdir -p ${REPO_DIR} ${OUTPUT_DIR} ; \ apt-get update ; \ apt-get install -y git ; \ cd ${REPO_DIR} ; \ git clone -b ${branch} --single-branch ${URL} . ; \ - cd packages/sshnoports ; \ - dart pub get ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshnp.dart -o ${OUTPUT_DIR}/sshnp ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshnpd.dart -o ${OUTPUT_DIR}/sshnpd ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshrv.dart -o ${OUTPUT_DIR}/sshrv ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshrvd.dart -o ${OUTPUT_DIR}/sshrvd ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/activate_cli.dart -o ${OUTPUT_DIR}/at_activate ; + dart pub get; \ + dart run melos bootstrap --scope="noports_core" --scope="sshnoports"; \ + dart pub get -C ${PACKAGE_DIR}; \ + dart compile exe ${PACKAGE_DIR}/bin/sshnp.dart -o ${OUTPUT_DIR}/sshnp ; \ + dart compile exe ${PACKAGE_DIR}/bin/sshnpd.dart -o ${OUTPUT_DIR}/sshnpd ; \ + dart compile exe ${PACKAGE_DIR}/bin/sshrv.dart -o ${OUTPUT_DIR}/sshrv ; \ + dart compile exe ${PACKAGE_DIR}/bin/sshrvd.dart -o ${OUTPUT_DIR}/sshrvd ; \ + dart compile exe ${PACKAGE_DIR}/bin/activate_cli.dart -o ${OUTPUT_DIR}/at_activate ; # RUNTIME BRANCH FROM base AS runtime-branch @@ -65,6 +68,7 @@ ENTRYPOINT cp -r /mount/. ${HOMEDIR} && sudo service ssh start && sh ${HOMEDIR}/ FROM dart:3.1.3@sha256:97cc20588eb7171f611606fff26bc04fb2aec5e68f7341060252a409bf7a86ce AS build-local ENV REPO_DIR=/app/repo +ENV PACKAGE_DIR=${REPO_DIR}/packages/sshnoports ENV OUTPUT_DIR=/app/output RUN mkdir -p ${REPO_DIR} ${OUTPUT_DIR} ; @@ -73,13 +77,15 @@ RUN mkdir -p ${REPO_DIR} ${OUTPUT_DIR} ; COPY . ${REPO_DIR} RUN set -eux ; \ - cd ${REPO_DIR}/packages/sshnoports ; \ - dart pub get ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshnp.dart -o ${OUTPUT_DIR}/sshnp ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshnpd.dart -o ${OUTPUT_DIR}/sshnpd ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshrv.dart -o ${OUTPUT_DIR}/sshrv ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/sshrvd.dart -o ${OUTPUT_DIR}/sshrvd ; \ - dart compile exe ${REPO_DIR}/packages/sshnoports/bin/activate_cli.dart -o ${OUTPUT_DIR}/at_activate ; + cd ${REPO_DIR}; \ + dart pub get; \ + dart run melos bootstrap --scope="noports_core" --scope="sshnoports"; \ + dart pub get -C ${PACKAGE_DIR}; \ + dart compile exe ${PACKAGE_DIR}/bin/sshnp.dart -o ${OUTPUT_DIR}/sshnp ; \ + dart compile exe ${PACKAGE_DIR}/bin/sshnpd.dart -o ${OUTPUT_DIR}/sshnpd ; \ + dart compile exe ${PACKAGE_DIR}/bin/sshrv.dart -o ${OUTPUT_DIR}/sshrv ; \ + dart compile exe ${PACKAGE_DIR}/bin/sshrvd.dart -o ${OUTPUT_DIR}/sshrvd ; \ + dart compile exe ${PACKAGE_DIR}/bin/activate_cli.dart -o ${OUTPUT_DIR}/at_activate ; # RUNTIME LOCAL FROM base AS runtime-local diff --git a/tools/manual-docker/blank/docker-compose.yaml b/tools/manual-docker/blank/docker-compose.yaml index caa2ca3a9..2b90a9a31 100644 --- a/tools/manual-docker/blank/docker-compose.yaml +++ b/tools/manual-docker/blank/docker-compose.yaml @@ -4,8 +4,8 @@ version: '3.8' services: image-manual-blank: build: - context: ../../../tests/end2end_tests/image/ - dockerfile: ./Dockerfile + context: ../../../ + dockerfile: ./tests/end2end_tests/image/Dockerfile target: manual-blank image: atsigncompany/sshnp-e2e-manual:blank deploy: diff --git a/tools/manual-docker/branch/docker-compose.yaml b/tools/manual-docker/branch/docker-compose.yaml index 5186fc782..13c3953ce 100644 --- a/tools/manual-docker/branch/docker-compose.yaml +++ b/tools/manual-docker/branch/docker-compose.yaml @@ -4,8 +4,8 @@ version: '3.8' services: image-manual-branch: build: - context: ../../../tests/end2end_tests/image/ - dockerfile: ./Dockerfile + context: ../../../ + dockerfile: ./tests/end2end_tests/image/Dockerfile target: manual-branch args: - branch=trunk diff --git a/tools/manual-docker/local/docker-compose.yaml b/tools/manual-docker/local/docker-compose.yaml index 2e8dbb4f5..36030b4ca 100644 --- a/tools/manual-docker/local/docker-compose.yaml +++ b/tools/manual-docker/local/docker-compose.yaml @@ -5,7 +5,7 @@ services: image-manual-local: build: context: ../../../ - dockerfile: tests/end2end_tests/image/Dockerfile + dockerfile: ./tests/end2end_tests/image/Dockerfile target: manual-local image: atsigncompany/sshnp-e2e-manual:local deploy: diff --git a/tools/manual-docker/release/docker-compose.yaml b/tools/manual-docker/release/docker-compose.yaml index ee18fbbe4..a333f07cc 100644 --- a/tools/manual-docker/release/docker-compose.yaml +++ b/tools/manual-docker/release/docker-compose.yaml @@ -4,8 +4,8 @@ version: '3.8' services: image-manual-release: build: - context: ../../../tests/end2end_tests/image/ - dockerfile: ./Dockerfile + context: ../../../ + dockerfile: ./tests/end2end_tests/image/Dockerfile target: manual-release image: atsigncompany/sshnp-e2e-manual:release deploy: diff --git a/tools/package-macos-arm64.sh b/tools/package-macos-arm64.sh index 3ffa8e749..27d9fafbb 100755 --- a/tools/package-macos-arm64.sh +++ b/tools/package-macos-arm64.sh @@ -21,7 +21,13 @@ else DART=$(which dart) fi -eval "$DART pub get -C $SRC_DIR" +restore_backup_and_exit() { + mv "$SRC_DIR/pubspec_overrides.back.yaml" "$SRC_DIR/pubspec_overrides.yaml" + exit "$1" +} + +mv "$SRC_DIR/pubspec_overrides.yaml" "$SRC_DIR/pubspec_overrides.back.yaml" +eval "$DART pub get -C $SRC_DIR" || restore_backup_and_exit 1 OUTPUT_DIR_PATH="$ROOT_DIRECTORY/build/macos-arm64" OUTPUT_DIR="$OUTPUT_DIR_PATH/sshnp" @@ -37,3 +43,5 @@ eval "$DART compile exe -o $OUTPUT_DIR/at_activate $SRC_DIR/bin/activate_cli.dar cp -r "$SRC_DIR/templates" "$OUTPUT_DIR/templates"; cp "$SRC_DIR"/LICENSE "$OUTPUT_DIR/"; + +restore_backup_and_exit 0