| // Copyright 2019 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:io'; |
| |
| import 'package:logging/logging.dart'; |
| import 'package:meta/meta.dart'; |
| import 'package:retry/retry.dart'; |
| |
| import 'exceptions.dart'; |
| |
| /// Allows access to the device via SSH. |
| /// |
| /// This class is used by [Sl4f] to start the SL4F server if it isn't running, |
| /// but it can also be used to run arbitrary shell commands on the device, and |
| /// to forward ports available only within the device. |
| /// |
| /// Note that in general SSH and CLI shells are a hack on top of Fuchsia which |
| /// doesn't really fit our process and interaction model. Before using [Ssh] |
| /// to run an arbitrary command, look at the other SL4F Client classes for |
| /// alternatives which often don't need SSH. For example: |
| /// |
| /// * Instead of `run` use [Component.launch] or [Modular.startBasemgr]. |
| /// * Instead of `cs` to see running component names use [Component.list]. |
| /// * Instead of `sessionctl` look at the [Modular] class. |
| /// * Instead of `trace` use the [Performance] class. |
| class Ssh { |
| static const _sshUser = 'fuchsia'; |
| |
| final _log = Logger('ssh'); |
| |
| /// Authority (IP, hostname, etc.) of the device under test. |
| final String target; |
| |
| /// SSH port to connect to the device under test, or null for default port |
| final int sshPort; |
| |
| /// Path to an SSH key file. By default, this can be |
| /// the resolved path of `~/.ssh/fuchsia_ed25519`. |
| final String sshKeyPath; |
| |
| /// Builds an SSH object that uses the credentials from a file. |
| Ssh(this.target, this.sshKeyPath, [this.sshPort]) |
| : assert(target != null && target.isNotEmpty), |
| assert(sshKeyPath != null && sshKeyPath.isNotEmpty), |
| assert(sshPort == null || sshPort > 0) { |
| _log.info('SSH key path: $sshKeyPath, setting owner only'); |
| // Swarming does not maintain file permissions any longer, so this file will |
| // be world-readable when it arrives on the tester bot. Ensure that it's not |
| // readable otherwise ssh will reject it. See https://fxbug.dev/53492 and |
| // https://crbug.com/1092020. |
| Process.run('chmod', ['og-rwx', sshKeyPath], runInShell: true); |
| } |
| |
| /// Builds an SSH object that uses the credentials from ssh-agent only. |
| Ssh.useAgent(this.target, [this.sshPort]) |
| : assert(target != null && target.isNotEmpty), |
| assert(sshPort == null || sshPort > 0), |
| sshKeyPath = null; |
| |
| /// Starts an ssh [Process], sending [cmd] to the target using ssh. |
| Future<Process> start(String cmd, |
| {ProcessStartMode mode = ProcessStartMode.normal}) { |
| _log.fine('Running over ssh: $cmd'); |
| |
| return Process.start('ssh', makeArgs(cmd), |
| // If not run in a shell it doesn't seem like the PATH is searched. |
| runInShell: true, |
| mode: mode); |
| } |
| |
| /// Runs the command given by [cmd] on the target using ssh. |
| /// |
| /// It can optionally send input via [stdin], and can optionally incrementally |
| /// emit output via [stdoutConsumer] and [stderrConsumer]. |
| /// |
| /// If the exit code is nonzero, diagnostic warnings are logged. |
| Future<ProcessResult> runWithOutput(String cmd, |
| {String stdin, |
| StreamConsumer<String> stdoutConsumer, |
| StreamConsumer<String> stderrConsumer}) async { |
| final process = await start(cmd); |
| |
| if (stdin != null) { |
| process.stdin.write(stdin); |
| } |
| |
| final localStdoutAll = StringBuffer(); |
| final localStderrAll = StringBuffer(); |
| |
| final localStdoutStream = |
| process.stdout.transform(systemEncoding.decoder).map((String data) { |
| localStdoutAll.write(data); |
| return data; |
| }); |
| final localStderrStream = |
| process.stderr.transform(systemEncoding.decoder).map((String data) { |
| localStderrAll.write(data); |
| return data; |
| }); |
| |
| // Note: The pipe and drain calls here create StreamSubscription objects |
| // that will only cancel when the corresponding stream closes. Since this |
| // function always waits for process to finish, we can rely on the stdout |
| // and stderr streams closing. If you are adding any code path that tries to |
| // kill the ssh process (like a timeout), these calls should be replaced |
| // with an equivalent that gives access to the underlying StreamSubscription |
| // so that it can be cancelled in the timeout as well, otherwise the dart |
| // process may hang after finishing its work due to the subscriptions being |
| // open. |
| final Future<void> stdoutFuture = (stdoutConsumer != null) |
| ? localStdoutStream.pipe(stdoutConsumer) |
| : localStdoutStream.drain(); |
| final Future<void> stderrFuture = (stderrConsumer != null) |
| ? localStderrStream.pipe(stderrConsumer) |
| : localStderrStream.drain(); |
| |
| Future<void> flushAndCloseStdin() async { |
| // These two need to be sequenced in order. |
| await process.stdin.flush(); |
| await process.stdin.close(); |
| } |
| |
| // Dart futures are run regardless of being awaited or not, so despite |
| // waiting sequentially here stdout and stderr are read from concurrently. |
| await flushAndCloseStdin(); |
| await stdoutFuture; |
| await stderrFuture; |
| |
| final result = ProcessResult( |
| process.pid, |
| await process.exitCode, |
| localStdoutAll.toString(), |
| localStderrAll.toString(), |
| ); |
| |
| if (result.exitCode != 0) { |
| _log |
| ..warning('$cmd; exit code: ${result.exitCode}') |
| ..warning(result.stdout) |
| ..warning(result.stderr); |
| } |
| |
| return result; |
| } |
| |
| /// Runs the command given by [cmd] on the target using ssh. |
| /// |
| /// It can optionally send input via [stdin]. If the exit code is nonzero, |
| /// diagnostic warnings are logged. |
| Future<ProcessResult> run(String cmd, {String stdin}) => |
| runWithOutput(cmd, stdin: stdin); |
| |
| /// Forwards TCP connections from the local [port] to the DUT's [remotePort]. |
| /// |
| /// If [port] is not provided, an unused port will be allocated. |
| /// The return value is the local forwarded port, or [PortForwardException] is |
| /// thrown in case of error. |
| Future<int> forwardPort( |
| {@required int remotePort, int port, int tries = 5}) async { |
| port ??= await pickUnusedPort(); |
| _log.fine('Forwarding TCP port: localhost:$port -> $target:$remotePort'); |
| await retry( |
| () => _forwardPort(remotePort, port), |
| retryIf: (e) => e is PortForwardException, |
| maxAttempts: tries, |
| ); |
| return port; |
| } |
| |
| /// Forwards a port to the DUT without retries. |
| Future<void> _forwardPort(int remotePort, int port) async { |
| final result = await Process.run( |
| 'ssh', makeForwardArgs(port, remotePort, cancel: false), |
| runInShell: true); |
| if (result.exitCode != 0) { |
| throw PortForwardException( |
| 'localhost:$port', |
| '$target:$remotePort', |
| 'Failed to initiate Port Forward. ' |
| 'STDOUT: "${result.stdout}". STDERR: "${result.stderr}".'); |
| } |
| } |
| |
| /// Cancels a TCP port forward. |
| /// |
| /// Completes to PortForwardException in case of failure. |
| Future<void> cancelPortForward( |
| {@required int remotePort, @required int port}) async { |
| _log.fine('Canceling TCP port forward: ' |
| 'localhost:$port -> $target:$remotePort'); |
| |
| final result = await Process.run( |
| 'ssh', makeForwardArgs(port, remotePort, cancel: true), |
| runInShell: true); |
| if (result.exitCode != 0) { |
| throw PortForwardException( |
| 'localhost:$port', |
| '$target:$remotePort', |
| 'Failed to cancel Port Forward. ' |
| 'STDOUT: "${result.stdout}". STDERR: "${result.stderr}".'); |
| } |
| } |
| |
| /// Forwards TCP connections from the DUT's [remotePort] to the local [port]. |
| /// |
| /// [PortForwardException] is thrown in case of error. |
| Future<int> forwardRemotePort( |
| {@required int remotePort, @required int port, int tries = 5}) async { |
| _log.fine('Forwarding TCP port: $target:$remotePort -> localhost:$port'); |
| await retry( |
| () => _forwardRemotePort(remotePort, port), |
| retryIf: (e) => e is PortForwardException, |
| maxAttempts: tries, |
| ); |
| return port; |
| } |
| |
| /// Forwards a port from the DUT to the host without retries. |
| Future<void> _forwardRemotePort(int remotePort, int port) async { |
| final result = await Process.run( |
| 'ssh', makeRemoteForwardArgs(remotePort, port, cancel: false), |
| runInShell: true); |
| if (result.exitCode != 0) { |
| throw PortForwardException( |
| '$target:$remotePort', |
| 'localhost:$port', |
| 'Failed to initiate Remote Port Forward. ' |
| 'STDOUT: "${result.stdout}". STDERR: "${result.stderr}".'); |
| } |
| } |
| |
| /// Cancels a TCP remote port forward. |
| /// |
| /// Completes to PortForwardException in case of failure. |
| Future<void> cancelRemotePortForward( |
| {@required int remotePort, @required int port}) async { |
| _log.fine('Canceling TCP port forward: ' |
| '$target:$remotePort -> localhost:$port'); |
| |
| final result = await Process.run( |
| 'ssh', makeRemoteForwardArgs(remotePort, port, cancel: true), |
| runInShell: true); |
| if (result.exitCode != 0) { |
| throw PortForwardException( |
| '$target:$remotePort', |
| 'localhost:$port', |
| 'Failed to cancel Remote Port Forward. ' |
| 'STDOUT: "${result.stdout}". STDERR: "${result.stderr}".'); |
| } |
| } |
| |
| /// Returns a list of arguments for ssh (and other ssh-like tools) containing |
| /// the default configuration options (i.e. -o arguments), as well as the path |
| /// to the ssh key if it's configured. The list does not contain the target's |
| /// user or host. |
| List<String> get defaultArguments => |
| [ |
| // Don't check known_hosts. |
| '-o', 'UserKnownHostsFile=/dev/null', |
| // Auto add the fingerprint of remote host. |
| '-o', 'StrictHostKeyChecking=no', |
| // Timeout to connect, short so the logs can make sense. |
| '-o', 'ConnectTimeout=2', |
| // These five arguments allow ssh to reuse its connection. |
| '-o', 'ControlPersist=yes', |
| '-o', 'ControlMaster=auto', |
| '-o', 'ControlPath=/tmp/fuchsia--%r@%h:%p', |
| '-o', 'ServerAliveInterval=1', |
| '-o', 'ServerAliveCountMax=1', |
| // These two arguments determine the connection timeout, |
| // in the case the ssh connection gets lost. |
| // They say if the target doesn't respond within 10 seconds, six |
| // times in a row, terminate the connection. |
| '-o', 'ServerAliveInterval=10', |
| '-o', 'ServerAliveCountMax=6', |
| ] + |
| (sshKeyPath != null ? ['-i', sshKeyPath] : []) + |
| (sshPort != null && sshPort != 0 ? ['-p', sshPort.toString()] : []); |
| |
| List<String> _makeBaseArgs() => defaultArguments + ['$_sshUser@$target']; |
| |
| @visibleForTesting |
| List<String> makeArgs(String cmd) => _makeBaseArgs() + [cmd]; |
| |
| @visibleForTesting |
| List<String> makeForwardArgs(int localPort, int remotePort, |
| {bool cancel = false}) => |
| _makeBaseArgs() + |
| [ |
| // Do Not run a command. |
| '-N', |
| // TCP port forward from local to remote. |
| '-L', 'localhost:$localPort:localhost:$remotePort', |
| // Forwarding with -O makes sure we are reusing the same connection. |
| '-O', cancel ? 'cancel' : 'forward', |
| ]; |
| |
| @visibleForTesting |
| List<String> makeRemoteForwardArgs(int remotePort, int localPort, |
| {bool cancel = false}) => |
| _makeBaseArgs() + |
| [ |
| // Do Not run a command. |
| '-N', |
| // TCP port forward from remote to local. |
| '-R', 'localhost:$remotePort:localhost:$localPort', |
| // Forwarding with -O makes sure we are reusing the same connection. |
| '-O', cancel ? 'cancel' : 'forward', |
| ]; |
| |
| /// Finds and returns an unused port on the test host in the local port range |
| /// (see ip(7)). |
| Future<int> pickUnusedPort() async { |
| // Use bind to allocate an unused port, then unbind from that port to |
| // make it available for use. |
| final socket = await ServerSocket.bind('localhost', 0); |
| final port = socket.port; |
| await socket.close(); |
| |
| return port; |
| } |
| } |