blob: 52c8dafff904839e6c7cc598650f0992afc45ec8 [file] [log] [blame]
// Copyright 2018 The Flutter 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:meta/meta.dart';
import 'package:process/process.dart';
import 'common/logging.dart';
import 'common/network.dart';
import 'dart/dart_vm.dart';
import 'runners/ssh_command_runner.dart';
final String _ipv4Loopback = InternetAddress.LOOPBACK_IP_V4.address;
final String _ipv6Loopback = InternetAddress.LOOPBACK_IP_V6.address;
const ProcessManager _processManager = const LocalProcessManager();
final Logger _log = new Logger('FuchsiaRemoteConnection');
/// A function for forwarding ports on the local machine to a remote device.
///
/// Takes a remote `address`, the target device's port, and an optional
/// `interface` and `configFile`. The config file is used primarily for the
/// default SSH port forwarding configuration.
typedef Future<PortForwarder> PortForwardingFunction(
String address, int remotePort,
[String interface, String configFile]);
/// The function for forwarding the local machine's ports to a remote Fuchsia
/// device.
///
/// Can be overwritten in the event that a different method is required.
/// Defaults to using SSH port forwarding.
PortForwardingFunction fuchsiaPortForwardingFunction = _SshPortForwarder.start;
/// Sets [fuchsiaPortForwardingFunction] back to the default SSH port forwarding
/// implementation.
void restoreFuchsiaPortForwardingFunction() {
fuchsiaPortForwardingFunction = _SshPortForwarder.start;
}
/// Manages a remote connection to a Fuchsia Device.
///
/// Provides affordances to observe and connect to Flutter views, isolates, and
/// perform actions on the Fuchsia device's various VM services.
///
/// Note that this class can be connected to several instances of the Fuchsia
/// device's Dart VM at any given time.
class FuchsiaRemoteConnection {
final List<PortForwarder> _forwardedVmServicePorts = <PortForwarder>[];
final SshCommandRunner _sshCommandRunner;
final bool _useIpV6Loopback;
/// VM service cache to avoid repeating handshakes across function
/// calls. Keys a forwarded port to a DartVm connection instance.
final Map<int, DartVm> _dartVmCache = <int, DartVm>{};
FuchsiaRemoteConnection._(this._useIpV6Loopback, this._sshCommandRunner);
/// Same as [FuchsiaRemoteConnection.connect] albeit with a provided
/// [SshCommandRunner] instance.
@visibleForTesting
static Future<FuchsiaRemoteConnection> connectWithSshCommandRunner(
SshCommandRunner commandRunner) async {
final FuchsiaRemoteConnection connection = new FuchsiaRemoteConnection._(
isIpV6Address(commandRunner.address), commandRunner);
await connection._forwardLocalPortsToDeviceServicePorts();
return connection;
}
/// Opens a connection to a Fuchsia device.
///
/// Accepts an `address` to a Fuchsia device, and optionally a `sshConfigPath`
/// in order to open the associated ssh_config for port forwarding.
///
/// Will throw an [ArgumentError] if `address` is malformed.
///
/// Once this function is called, the instance of [FuchsiaRemoteConnection]
/// returned will keep all associated DartVM connections opened over the
/// lifetime of the object.
///
/// At its current state Dart VM connections will not be added or removed over
/// the lifetime of this object.
///
/// Throws an [ArgumentError] if the supplied `address` is not valid IPv6 or
/// IPv4.
///
/// Note that if `address` is ipv6 link local (usually starts with fe80::),
/// then `interface` will probably need to be set in order to connect
/// successfully (that being the outgoing interface of your machine, not the
/// interface on the target machine).
static Future<FuchsiaRemoteConnection> connect(
String address, [
String interface = '',
String sshConfigPath,
]) async {
return await FuchsiaRemoteConnection.connectWithSshCommandRunner(
new SshCommandRunner(
address: address,
interface: interface,
sshConfigPath: sshConfigPath,
),
);
}
/// Closes all open connections.
///
/// Any objects that this class returns (including any child objects from
/// those objects) will subsequently have its connection closed as well, so
/// behavior for them will be undefined.
Future<Null> stop() async {
for (PortForwarder fp in _forwardedVmServicePorts) {
// Closes VM service first to ensure that the connection is closed cleanly
// on the target before shutting down the forwarding itself.
final DartVm vmService = _dartVmCache[fp.port];
_dartVmCache[fp.port] = null;
await vmService?.stop();
await fp.stop();
}
_dartVmCache.clear();
_forwardedVmServicePorts.clear();
}
/// Returns a list of [FlutterView] objects.
///
/// This is run across all connected DartVM connections that this class is
/// managing.
Future<List<FlutterView>> getFlutterViews() async {
final List<FlutterView> views = <FlutterView>[];
if (_forwardedVmServicePorts.isEmpty) {
return views;
}
for (PortForwarder fp in _forwardedVmServicePorts) {
final DartVm vmService = await _getDartVm(fp.port);
views.addAll(await vmService.getAllFlutterViews());
}
return new List<FlutterView>.unmodifiable(views);
}
Future<DartVm> _getDartVm(int port) async {
if (!_dartVmCache.containsKey(port)) {
// While the IPv4 loopback can be used for the initial port forwarding
// (see [PortForwarder.start]), the address is actually bound to the IPv6
// loopback device, so connecting to the IPv4 loopback would fail when the
// target address is IPv6 link-local.
final String addr = _useIpV6Loopback
? 'http://\[$_ipv6Loopback\]:$port'
: 'http://$_ipv4Loopback:$port';
final Uri uri = Uri.parse(addr);
final DartVm dartVm = await DartVm.connect(uri);
_dartVmCache[port] = dartVm;
}
return _dartVmCache[port];
}
/// Forwards a series of local device ports to the remote device.
///
/// When this function is run, all existing forwarded ports and connections
/// are reset by way of [stop].
Future<Null> _forwardLocalPortsToDeviceServicePorts() async {
await stop();
final List<int> servicePorts = await getDeviceServicePorts();
_forwardedVmServicePorts
.addAll(await Future.wait(servicePorts.map((int deviceServicePort) {
return fuchsiaPortForwardingFunction(
_sshCommandRunner.address,
deviceServicePort,
_sshCommandRunner.interface,
_sshCommandRunner.sshConfigPath);
})));
}
/// Gets the open Dart VM service ports on a remote Fuchsia device.
///
/// The method attempts to get service ports through an SSH connection. Upon
/// successfully getting the VM service ports, returns them as a list of
/// integers. If an empty list is returned, then no Dart VM instances could be
/// found. An exception is thrown in the event of an actual error when
/// attempting to acquire the ports.
Future<List<int>> getDeviceServicePorts() async {
// TODO(awdavies): This is using a temporary workaround rather than a
// well-defined service, and will be deprecated in the near future.
final List<String> lsOutput =
await _sshCommandRunner.run('ls /tmp/dart.services');
final List<int> ports = <int>[];
// The output of lsOutput is a list of available ports as the Fuchsia dart
// service advertises. An example lsOutput would look like:
//
// [ '31782\n', '1234\n', '11967' ]
for (String s in lsOutput) {
final String trimmed = s.trim();
final int lastSpace = trimmed.lastIndexOf(' ');
final String lastWord = trimmed.substring(lastSpace + 1);
if ((lastWord != '.') && (lastWord != '..')) {
// ignore: deprecated_member_use
final int value = int.parse(lastWord, onError: (_) => null);
if (value != null) {
ports.add(value);
}
}
}
return ports;
}
}
/// Defines an interface for port forwarding.
///
/// When a port forwarder is initialized, it is intended to save a port through
/// which a connection is persisted along the lifetime of this object.
///
/// To shut down a port forwarder you must call the [stop] function.
abstract class PortForwarder {
/// Determines the port which is being forwarded from the local machine.
int get port;
/// The destination port on the other end of the port forwarding tunnel.
int get remotePort;
/// Shuts down and cleans up port forwarding.
Future<Null> stop();
}
/// Instances of this class represent a running SSH tunnel.
///
/// The SSH tunnel is from the host to a VM service running on a Fuchsia device.
class _SshPortForwarder implements PortForwarder {
_SshPortForwarder._(
this._remoteAddress,
this._remotePort,
this._localSocket,
this._process,
this._interface,
this._sshConfigPath,
this._ipV6,
);
final String _remoteAddress;
final int _remotePort;
final ServerSocket _localSocket;
final Process _process;
final String _sshConfigPath;
final String _interface;
final bool _ipV6;
@override
int get port => _localSocket.port;
@override
int get remotePort => _remotePort;
/// Starts SSH forwarding through a subprocess, and returns an instance of
/// [_SshPortForwarder].
static Future<_SshPortForwarder> start(String address, int remotePort,
[String interface, String sshConfigPath]) async {
final bool isIpV6 = isIpV6Address(address);
final ServerSocket localSocket = await _createLocalSocket();
if (localSocket == null || localSocket.port == 0) {
_log.warning('_SshPortForwarder failed to find a local port for '
'$address:$remotePort');
return null;
}
// TODO(awdavies): The square-bracket enclosure for using the IPv6 loopback
// didn't appear to work, but when assigning to the IPv4 loopback device,
// netstat shows that the local port is actually being used on the IPv6
// loopback (::1). While this can be used for forwarding to the destination
// IPv6 interface, it cannot be used to connect to a websocket.
final String formattedForwardingUrl =
'${localSocket.port}:$_ipv4Loopback:$remotePort';
final List<String> command = <String>['ssh'];
if (isIpV6) {
command.add('-6');
}
if (sshConfigPath != null) {
command.addAll(<String>['-F', sshConfigPath]);
}
final String targetAddress =
isIpV6 && interface.isNotEmpty ? '$address%$interface' : address;
const String dummyRemoteCommand = 'date';
command.addAll(<String>[
'-nNT',
'-f',
'-L',
formattedForwardingUrl,
targetAddress,
dummyRemoteCommand,
]);
_log.fine("_SshPortForwarder running '${command.join(' ')}'");
final Process process = await _processManager.start(command);
process.exitCode.then((int c) {
_log.fine("'${command.join(' ')}' exited with exit code $c");
});
_log.fine(
'Set up forwarding from ${localSocket.port} to $address port $remotePort');
return new _SshPortForwarder._(address, remotePort, localSocket, process,
interface, sshConfigPath, isIpV6);
}
/// Kills the SSH forwarding command, then to ensure no ports are forwarded,
/// runs the SSH 'cancel' command to shut down port forwarding completely.
@override
Future<Null> stop() async {
// Kill the original SSH process if it is still around.
_process.kill();
// Cancel the forwarding request. See [start] for commentary about why this
// uses the IPv4 loopback.
final String formattedForwardingUrl =
'${_localSocket.port}:$_ipv4Loopback:$_remotePort';
final List<String> command = <String>['ssh'];
final String targetAddress = _ipV6 && _interface.isNotEmpty
? '$_remoteAddress%$_interface'
: _remoteAddress;
if (_sshConfigPath != null) {
command.addAll(<String>['-F', _sshConfigPath]);
}
command.addAll(<String>[
'-O',
'cancel',
'-L',
formattedForwardingUrl,
targetAddress,
]);
_log.fine(
'Shutting down SSH forwarding with command: ${command.join(' ')}');
final ProcessResult result = await _processManager.run(command);
if (result.exitCode != 0) {
_log.warning(
'Command failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}');
}
_localSocket.close();
}
/// Attempts to find an available port.
///
/// If successful returns a valid [ServerSocket] (which must be disconnected
/// later).
static Future<ServerSocket> _createLocalSocket() async {
ServerSocket s;
try {
s = await ServerSocket.bind(_ipv4Loopback, 0);
} catch (e) {
// Failures are signaled by a return value of 0 from this function.
_log.warning('_createLocalSocket failed: $e');
return null;
}
return s;
}
}