| // Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file | |
| // for details. 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:convert'; | |
| import 'dart:io'; | |
| import 'dart:isolate'; | |
| import 'dart:math'; | |
| import 'dart:typed_data'; | |
| import 'package:args/args.dart'; | |
| import 'package:dart_data_home/dart_data_home.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:shelf/shelf.dart'; | |
| import 'package:shelf/shelf_io.dart' as io; | |
| import 'package:shelf_web_socket/shelf_web_socket.dart'; | |
| import 'package:sse/server/sse_handler.dart'; | |
| import 'package:web_socket_channel/web_socket_channel.dart'; | |
| import 'constants.dart'; | |
| import 'dtd_client.dart'; | |
| import 'dtd_client_manager.dart'; | |
| import 'dtd_connection_info.dart'; | |
| import 'dtd_stream_manager.dart'; | |
| import 'service/connected_app_service.dart'; | |
| import 'service/file_system_service.dart'; | |
| import 'service/internal_service.dart'; | |
| import 'service/unified_analytics_service.dart'; | |
| /// The default value for the --ping-interval option. | |
| /// | |
| /// This value was initially 15 seconds so that all DTD clients had the ping | |
| /// enabled to avoid issues like Norton Antivirus dropping proxied connections | |
| /// if there was no traffic for 30s (see | |
| /// https://github.com/Dart-Code/Dart-Code/issues/5794). | |
| /// | |
| /// However, IntelliJ's WebSocket client turns out to not handle pings (see | |
| /// https://github.com/flutter/dart-intellij-third-party/issues/205) and this | |
| /// resulted in dropped connections. Since we can't change already-shipped IJ | |
| /// plugins we now default to no pings (because breaking IJ for all users is | |
| /// worse than breaking users on non-VSCode/IJ clients that happen to have | |
| /// something like Norton). | |
| /// | |
| /// We should consider changing this back to 15s once IJ has been fixed and that | |
| /// fix is in all versions of the IJ plugins we support/care about. | |
| const _pingIntervalSecondsDefault = 0; | |
| /// The directory name within the user's Dart data home directory where the | |
| /// Dart Tooling Daemon stores its connection info (pid-files). | |
| const String dtdDirName = 'dtd'; | |
| /// The message printed when no running Dart Tooling Daemon instances are found | |
| /// after a user queries the list of running DTD processes with the `--list` | |
| /// argument. | |
| const String noInstancesMessage = | |
| 'No running debug processes or DTD process were found, try ' | |
| 'starting up your app from your IDE, `flutter run`, `dart run`, ' | |
| 'or connecting to it with `flutter attach`.'; | |
| /// Contains all the flags and options used by the DTD argument parser. | |
| enum DartToolingDaemonOptions { | |
| // Used when executing a training run while generating an AppJIT snapshot as | |
| // part of an SDK build. | |
| train.flag('train', negatable: false, hide: true), | |
| machine.flag( | |
| 'machine', | |
| negatable: false, | |
| help: 'Sets output format to JSON for consumption in tools.', | |
| ), | |
| port.option( | |
| 'port', | |
| defaultsTo: '0', | |
| help: 'Sets the port to bind DTD to (0 for automatic port).', | |
| ), | |
| unrestricted.flag( | |
| 'unrestricted', | |
| negatable: false, | |
| help: 'Disables restrictions on services registered by DTD.', | |
| ), | |
| disableServiceAuthCodes.flag( | |
| 'disable-service-auth-codes', | |
| negatable: false, | |
| // This text mirrors what's in dartdev/commands/run for VM Service. | |
| help: | |
| 'Disables the requirement for an authentication code to ' | |
| 'communicate with DTD. Authentication codes help ' | |
| 'protect against CSRF attacks, so it is not recommended to ' | |
| 'disable them unless behind a firewall on a secure device.', | |
| verbose: true, | |
| ), | |
| fakeAnalytics.flag( | |
| 'fakeAnalytics', | |
| negatable: false, | |
| help: 'Uses fake analytics instances for the UnifiedAnalytics service.', | |
| hide: true, | |
| ), | |
| list.flag( | |
| 'list', | |
| negatable: false, | |
| help: 'Lists all running Dart Tooling Daemon instances on this machine.', | |
| ), | |
| pingInterval.option( | |
| 'ping-interval', | |
| defaultsTo: '$_pingIntervalSecondsDefault', | |
| help: | |
| 'Sets the WebSocket ping interval in seconds (0 to disable). ' | |
| 'Enabling ping helps avoid connections being dropped by some proxies/' | |
| 'antivirus products if a connection has no traffic for some period.', | |
| ); | |
| const DartToolingDaemonOptions.flag( | |
| this.name, { | |
| this.negatable = true, | |
| this.verbose = false, | |
| this.hide = false, | |
| this.help, | |
| }) : _kind = _DartToolingDaemonOptionKind.flag, | |
| defaultsTo = null; | |
| const DartToolingDaemonOptions.option(this.name, {this.defaultsTo, this.help}) | |
| : _kind = _DartToolingDaemonOptionKind.option, | |
| negatable = false, | |
| verbose = false, | |
| hide = false; | |
| final String name; | |
| final _DartToolingDaemonOptionKind _kind; | |
| final String? defaultsTo; | |
| final bool negatable; | |
| final bool hide; | |
| final String? help; | |
| /// Show in help only when --verbose passed. | |
| final bool verbose; | |
| /// Populates an argument parser that can be used to configure the daemon. | |
| static void populateArgOptions(ArgParser argParser, {bool verbose = false}) { | |
| for (final entry in DartToolingDaemonOptions.values) { | |
| final hide = entry.hide || (entry.verbose && !verbose); | |
| switch (entry._kind) { | |
| case _DartToolingDaemonOptionKind.flag: | |
| argParser.addFlag( | |
| entry.name, | |
| negatable: entry.negatable, | |
| hide: hide, | |
| help: entry.help, | |
| ); | |
| case _DartToolingDaemonOptionKind.option: | |
| argParser.addOption( | |
| entry.name, | |
| hide: hide, | |
| help: entry.help, | |
| defaultsTo: entry.defaultsTo, | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| /// The kind of command line argument. | |
| enum _DartToolingDaemonOptionKind { flag, option } | |
| /// TODO(https://github.com/dart-lang/sdk/issues/54429): Add shutdown behavior. | |
| /// A service that facilitates communication between dart tools. | |
| class DartToolingDaemon { | |
| /// Used to override the environment variables for `dart_data_home` during | |
| /// tests. | |
| @visibleForTesting | |
| static Map<String, String>? environmentOverride; | |
| DartToolingDaemon._({ | |
| required this.secret, | |
| required bool unrestrictedMode, | |
| bool disableServiceAuthCodes = false, | |
| this._ipv6 = false, | |
| this._shouldLogRequests = false, | |
| bool useFakeAnalytics = false, | |
| this.pingInterval, | |
| }) : _uriAuthCode = disableServiceAuthCodes ? null : _generateSecret() { | |
| streamManager = DTDStreamManager(this); | |
| clientManager = DTDClientManager(); | |
| internalServices = Map.fromEntries( | |
| [ | |
| ConnectedAppService(secret: secret, unrestrictedMode: unrestrictedMode), | |
| FileSystemService(secret: secret, unrestrictedMode: unrestrictedMode), | |
| UnifiedAnalyticsService(fake: useFakeAnalytics), | |
| ].map((service) => MapEntry(service.serviceName, service)), | |
| ); | |
| } | |
| static const _kSseHandlerPath = '\$debugHandler'; | |
| /// The ping interval to be set on any WebSocket connections. | |
| /// | |
| /// Having ping enabled can prevent proxies (including antivirus) from | |
| /// dropping connections to DTD because they appear idle. | |
| final Duration? pingInterval; | |
| /// Manages the streams for the current [DartToolingDaemon] service. | |
| late final DTDStreamManager streamManager; | |
| /// Manages the connected clients of the current [DartToolingDaemon] service. | |
| late final DTDClientManager clientManager; | |
| final bool _ipv6; | |
| late HttpServer _server; | |
| final bool _shouldLogRequests; | |
| /// A map of internal DTD services, keyed by the service name. | |
| late final Map<String, InternalService> internalServices; | |
| final String secret; | |
| /// If non-null, any requests to DTD must have this code as the first element | |
| /// of the uri path. | |
| /// | |
| /// This provides an obfuscation step to prevent bad actors from stumbling | |
| /// onto the dtd address. | |
| final String? _uriAuthCode; | |
| /// The uri of the current [DartToolingDaemon] service. | |
| Uri? get uri => _uri; | |
| Uri? _uri; | |
| /// The path to the file containing this daemon's connection info, if started. | |
| String? _pidFilePath; | |
| Future<void> _startService({required int port}) async { | |
| final host = | |
| (_ipv6 ? InternetAddress.loopbackIPv6 : InternetAddress.loopbackIPv4) | |
| .host; | |
| // Start the DTD server. Run in an error Zone to ensure that asynchronous | |
| // exceptions encountered during request handling are handled, as exceptions | |
| // thrown during request handling shouldn't take down the entire service. | |
| late String errorMessage; | |
| final tmpServer = await runZonedGuarded( | |
| () async { | |
| Future<HttpServer?> startServer() async { | |
| try { | |
| return await io.serve(_handlers(), host, port); | |
| } on SocketException catch (e) { | |
| errorMessage = e.message; | |
| if (e.osError != null) { | |
| errorMessage += ' (${e.osError!.message})'; | |
| } | |
| errorMessage += ': ${e.address?.host}:${e.port}'; | |
| return null; | |
| } | |
| } | |
| return await startServer(); | |
| }, | |
| (error, stack) { | |
| if (_shouldLogRequests) { | |
| print('Asynchronous error: $error\n$stack'); | |
| } | |
| }, | |
| ); | |
| if (tmpServer == null) { | |
| throw DartToolingDaemonException.connectionIssue(errorMessage); | |
| } | |
| _server = tmpServer; | |
| _uri = Uri( | |
| scheme: 'ws', | |
| host: host, | |
| port: _server.port, | |
| path: _uriAuthCode != null ? '/$_uriAuthCode' : '', | |
| ); | |
| } | |
| /// Starts a [DartToolingDaemon] service. | |
| /// | |
| /// Set [ipv6] to true to have the service use ipv6 instead of ipv4. | |
| /// | |
| /// Set [shouldLogRequests] to true to enable logging. | |
| /// | |
| /// When [sendPort] is non-null, information about the DTD connection will be | |
| /// sent over [sendPort] instead of being printed to stdout. | |
| static Future<DartToolingDaemon?> startService( | |
| List<String> args, { | |
| bool ipv6 = false, | |
| bool shouldLogRequests = false, | |
| SendPort? sendPort, | |
| }) async { | |
| final argParser = ArgParser(); | |
| DartToolingDaemonOptions.populateArgOptions(argParser); | |
| final parsedArgs = argParser.parse(args); | |
| if (parsedArgs.wasParsed(DartToolingDaemonOptions.train.name)) { | |
| return null; | |
| } | |
| final machineMode = | |
| parsedArgs[DartToolingDaemonOptions.machine.name] as bool; | |
| final listMode = parsedArgs[DartToolingDaemonOptions.list.name] as bool; | |
| if (listMode) { | |
| _listRunningDtdInstances(machineMode: machineMode); | |
| return null; | |
| } | |
| final unrestrictedMode = | |
| parsedArgs[DartToolingDaemonOptions.unrestricted.name] as bool; | |
| final disableServiceAuthCodes = | |
| parsedArgs[DartToolingDaemonOptions.disableServiceAuthCodes.name] | |
| as bool; | |
| final useFakeAnalytics = | |
| parsedArgs[DartToolingDaemonOptions.fakeAnalytics.name] as bool; | |
| final port = | |
| int.tryParse( | |
| parsedArgs[DartToolingDaemonOptions.port.name] as String? ?? '', | |
| ) ?? | |
| 0; | |
| final pingIntervalSeconds = | |
| int.tryParse( | |
| parsedArgs[DartToolingDaemonOptions.pingInterval.name] as String? ?? | |
| '', | |
| ) ?? | |
| _pingIntervalSecondsDefault; | |
| final pingInterval = pingIntervalSeconds == 0 | |
| ? null | |
| : Duration(seconds: pingIntervalSeconds); | |
| final secret = _generateSecret(); | |
| final dtd = DartToolingDaemon._( | |
| secret: secret, | |
| unrestrictedMode: unrestrictedMode, | |
| disableServiceAuthCodes: disableServiceAuthCodes, | |
| ipv6: ipv6, | |
| shouldLogRequests: shouldLogRequests, | |
| useFakeAnalytics: useFakeAnalytics, | |
| pingInterval: pingInterval, | |
| ); | |
| await dtd._startService(port: port); | |
| dtd._recordDtdConnectionInfo(); | |
| if (machineMode) { | |
| final encoded = jsonEncode({ | |
| 'tooling_daemon_details': { | |
| 'uri': dtd.uri.toString(), | |
| ...(!unrestrictedMode | |
| ? {'trusted_client_secret': secret} | |
| : <String, Object?>{}), | |
| }, | |
| }); | |
| if (sendPort == null) { | |
| print(encoded); | |
| } else { | |
| sendPort.send(encoded); | |
| } | |
| } else { | |
| print( | |
| 'The Dart Tooling Daemon is listening on ' | |
| '${dtd.uri.toString()}', | |
| ); | |
| if (!unrestrictedMode) { | |
| print('Trusted Client Secret: $secret'); | |
| } | |
| } | |
| return dtd; | |
| } | |
| bool _isAllowedOrigin(String origin) { | |
| Uri uri; | |
| try { | |
| uri = Uri.parse(origin); | |
| } catch (_) { | |
| return false; | |
| } | |
| // Is it loopback? | |
| if (uri.host == 'localhost' || | |
| uri.host == '127.0.0.1' || | |
| uri.host == '::1') { | |
| return true; | |
| } | |
| // Is it the bound address? | |
| if (uri.host == _server.address.host && uri.port == _server.port) { | |
| return true; | |
| } | |
| // Is it the bound address IP? | |
| if (uri.host == _server.address.address && uri.port == _server.port) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| Handler _originCheckMiddleware(Handler innerHandler) => (Request request) { | |
| // Validate Host header first to prevent DNS rebinding. | |
| final hostHeader = request.headers[HttpHeaders.hostHeader]; | |
| if (hostHeader == null) { | |
| return Response.forbidden('forbidden host'); | |
| } | |
| if (!_isAllowedOrigin('http://$hostHeader')) { | |
| return Response.forbidden('forbidden host'); | |
| } | |
| // Check the Origin header for cross-origin requests. | |
| final origin = request.headers['Origin']; | |
| if (origin == null) { | |
| // No origin sent. This is a non-browser client or a same-origin request. | |
| // Since we already validated the Host header, we know it's a legitimate | |
| // local same-origin request (or a local non-browser tool). | |
| return innerHandler(request); | |
| } | |
| if (_isAllowedOrigin(origin)) { | |
| return innerHandler(request); | |
| } | |
| return Response.forbidden('forbidden origin'); | |
| }; | |
| // Attempt to upgrade HTTP requests to a websocket before processing them as | |
| // standard HTTP requests. The websocket handler will fail quickly if the | |
| // request doesn't appear to be a websocket upgrade request. | |
| Handler _handlers() { | |
| return const Pipeline() | |
| .addMiddleware(_originCheckMiddleware) | |
| .addMiddleware(_uriTokenHandler) | |
| .addHandler( | |
| Cascade().add(_webSocketHandler()).add(_sseHandler()).handler, | |
| ); | |
| } | |
| Handler _uriTokenHandler(Handler innerHandler) => (Request request) { | |
| if (_uriAuthCode != null) { | |
| final forbidden = Response.forbidden( | |
| 'missing or invalid authentication code', | |
| ); | |
| final pathSegments = request.url.pathSegments; | |
| if (pathSegments.isEmpty) { | |
| return forbidden; | |
| } | |
| final clientProvidedCode = pathSegments[0]; | |
| if (clientProvidedCode != _uriAuthCode) { | |
| return forbidden; | |
| } | |
| } | |
| return innerHandler(request); | |
| }; | |
| // Note: the WebSocketChannel type below is needed for compatibility with | |
| // package:shelf_web_socket v2. | |
| Handler _webSocketHandler() => webSocketHandler((WebSocketChannel ws, _) { | |
| final client = DTDClient.fromWebSocket(this, ws); | |
| _registerInternalServiceMethods(client); | |
| clientManager.addClient(client); | |
| }, pingInterval: pingInterval); | |
| Handler _sseHandler() { | |
| final handler = SseHandler( | |
| Uri.parse('/$_kSseHandlerPath'), | |
| keepAlive: sseKeepAlive, | |
| ); | |
| handler.connections.rest.listen((sseConnection) { | |
| final client = DTDClient.fromSSEConnection(this, sseConnection); | |
| _registerInternalServiceMethods(client); | |
| clientManager.addClient(client); | |
| }); | |
| return handler.handler; | |
| } | |
| void _registerInternalServiceMethods(DTDClient client) { | |
| for (final service in internalServices.values) { | |
| service.register(client); | |
| } | |
| } | |
| static String _generateSecret() { | |
| final kTokenByteSize = 8; | |
| var bytes = Uint8List(kTokenByteSize); | |
| Random rand; | |
| // Random.secure() may throw UnsupportedError if the platform does not | |
| // provide a cryptographically secure source of entropy (e.g., in some | |
| // sandboxed CI environments). Fall back to a pseudo-random generator | |
| // in this case. | |
| try { | |
| rand = Random.secure(); | |
| // ignore: avoid_catching_errors | |
| } on UnsupportedError { | |
| rand = Random(); | |
| } | |
| for (var i = 0; i < kTokenByteSize; i++) { | |
| bytes[i] = rand.nextInt(256); | |
| } | |
| return base64Url.encode(bytes); | |
| } | |
| Future<void> close() async { | |
| // Delete the connection info file on graceful shutdown. | |
| if (_pidFilePath != null) { | |
| try { | |
| final file = File(_pidFilePath!); | |
| if (file.existsSync()) { | |
| file.deleteSync(); | |
| } | |
| } catch (_) { | |
| // Ignore errors during file deletion. | |
| } | |
| } | |
| await clientManager.shutdown(); | |
| for (final service in internalServices.values) { | |
| service.shutdown(); | |
| } | |
| await _server.close(force: true); | |
| } | |
| static String? _getDtdDataHome() { | |
| try { | |
| final dir = getDartDataHome(dtdDirName, environment: environmentOverride); | |
| // createSync(recursive: true) is a no-op if the directory already exists. | |
| Directory(dir).createSync(recursive: true); | |
| return dir; | |
| } catch (_) { | |
| // Ignore errors when creating local data home directory. | |
| return null; | |
| } | |
| } | |
| /// Writes out DTD connection information to a file named with the DTD process | |
| /// PID in the [dtdDirName] directory within the Dart data home directory. | |
| void _recordDtdConnectionInfo() { | |
| final dataHome = _getDtdDataHome(); | |
| if (dataHome == null) return; | |
| try { | |
| final info = DTDConnectionInfo( | |
| wsUri: uri.toString(), | |
| epoch: DateTime.now().millisecondsSinceEpoch, | |
| pid: pid, | |
| dartVersion: Platform.version, | |
| workspaceRoot: Directory.current.path, | |
| ideName: Platform.environment['DASH__IDE_NAME'], | |
| ); | |
| if (createPidFile(dataHome, jsonEncode(info.toJson()))) { | |
| _pidFilePath = p.join(dataHome, pid.toString()); | |
| } | |
| } catch (_) { | |
| // Ignore errors when writing the pid file. | |
| } | |
| } | |
| /// Lists all running DTD instances on the local system. | |
| static void _listRunningDtdInstances({bool machineMode = false}) { | |
| final dataHome = _getDtdDataHome(); | |
| if (dataHome == null) return; | |
| final instances = <DTDConnectionInfo>[]; | |
| final pidFiles = listPidFiles(dataHome); | |
| for (final value in pidFiles.values) { | |
| try { | |
| final json = jsonDecode(value) as Map<String, Object?>; | |
| instances.add(DTDConnectionInfo.fromJson(json)); | |
| } catch (_) { | |
| // ignore | |
| } | |
| } | |
| // Sort instances by epoch time descending so the most recently | |
| // launched processes are listed first. | |
| instances.sort((a, b) => b.epoch.compareTo(a.epoch)); | |
| if (machineMode) { | |
| print(jsonEncode(instances.map((e) => e.toJson()).toList())); | |
| return; | |
| } | |
| if (instances.isEmpty) { | |
| print(noInstancesMessage); | |
| } else { | |
| print('Found ${instances.length} Dart Tooling Daemon instance(s):'); | |
| for (final info in instances) { | |
| print(info); | |
| } | |
| } | |
| } | |
| } | |
| // TODO(danchevalier): clean up these exceptions so they are more relevant to | |
| // DTD. Also add docs to the factories that remain. | |
| class DartToolingDaemonException implements Exception { | |
| // TODO(danchevalier): add a relevant dart doc here | |
| static const int existingDtdInstanceError = 1; | |
| /// Set when the connection to the remote VM service terminates unexpectedly | |
| /// during Dart Development Service startup. | |
| static const int failedToStartError = 2; | |
| /// Set when a connection error has occurred after startup. | |
| static const int connectionError = 3; | |
| factory DartToolingDaemonException.existingDtdInstance( | |
| String message, { | |
| Uri? dtdUri, | |
| }) { | |
| return ExistingDTDImplException._(message, dtdUri: dtdUri); | |
| } | |
| factory DartToolingDaemonException.failedToStart() { | |
| return DartToolingDaemonException._( | |
| failedToStartError, | |
| 'Failed to start Dart Development Service', | |
| ); | |
| } | |
| factory DartToolingDaemonException.connectionIssue(String message) { | |
| return DartToolingDaemonException._(connectionError, message); | |
| } | |
| DartToolingDaemonException._(this.errorCode, this.message); | |
| @override | |
| String toString() => 'DartDevelopmentServiceException: $message'; | |
| final int errorCode; | |
| final String message; | |
| } | |
| class ExistingDTDImplException extends DartToolingDaemonException { | |
| ExistingDTDImplException._(String message, {this.dtdUri}) | |
| : super._(DartToolingDaemonException.existingDtdInstanceError, message); | |
| /// The URI of the existing DTD instance, if available. | |
| /// | |
| /// This URL is the base HTTP URI such as `http://127.0.0.1:1234/AbcDefg=/`, | |
| /// not the WebSocket URI (which can be obtained by mapping the scheme to | |
| /// `ws` (or `wss`) and appending `ws` to the path segments). | |
| final Uri? dtdUri; | |
| } |