| // 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; |
| } |
| } |