blob: 96ddc853549836164e29e419ca4625b0ca251430 [file] [log] [blame]
// 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;
}
}