blob: 3c49f0f458c17317454c05010e2509a4764eb172 [file] [log] [blame]
// Copyright (c) 2013, 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.
part of 'dart:vmservice_io';
// TODO(48602): deprecate SILENT_OBSERVATORY in favor of SILENT_VM_SERVICE
bool silentObservatory = bool.fromEnvironment('SILENT_OBSERVATORY');
bool silentVMService = bool.fromEnvironment('SILENT_VM_SERVICE');
void serverPrint(String s) {
if (silentObservatory || silentVMService) {
// We've been requested to be silent.
return;
}
print(s);
}
class WebSocketClient extends Client {
static const int parseErrorCode = 4000;
static const int binaryMessageErrorCode = 4001;
static const int notMapErrorCode = 4002;
static const int idErrorCode = 4003;
final WebSocket socket;
WebSocketClient(this.socket, VMService service) : super(service) {
socket.listen((message) => onWebSocketMessage(message));
socket.done.then((_) => close());
}
Future<void> disconnect() => socket.close();
void onWebSocketMessage(message) {
if (message is String) {
dynamic jsonObj;
try {
jsonObj = json.decode(message);
} catch (e) {
socket.close(parseErrorCode, 'Message parse error: $e');
return;
}
if (jsonObj is! Map<String, dynamic>) {
socket.close(notMapErrorCode, 'Message must be a JSON map.');
return;
}
final Map<String, dynamic> map = jsonObj;
final rpc = Message.fromJsonRpc(this, map);
switch (rpc.type) {
case MessageType.Request:
onRequest(rpc);
break;
case MessageType.Notification:
onNotification(rpc);
break;
case MessageType.Response:
onResponse(rpc);
break;
}
} else {
socket.close(binaryMessageErrorCode, 'Message must be a string.');
}
}
void post(Response? result) {
if (result == null) {
// The result of a notification event. Do nothing.
return;
}
try {
switch (result.kind) {
case ResponsePayloadKind.String:
case ResponsePayloadKind.Binary:
socket.add(result.payload);
break;
case ResponsePayloadKind.Utf8String:
socket.addUtf8Text(result.payload as List<int>);
break;
}
} on StateError catch (_) {
// VM has shutdown, do nothing.
return;
}
}
Map<String, dynamic> toJson() => {
...super.toJson(),
'type': 'WebSocketClient',
'socket': '$socket',
};
}
class HttpRequestClient extends Client {
static final jsonContentType = ContentType(
'application',
'json',
charset: 'utf-8',
);
final HttpRequest request;
HttpRequestClient(this.request, VMService service)
: super(service, sendEvents: false);
Future<void> disconnect() async {
await request.response.close();
close();
}
void post(Response? result) {
if (result == null) {
// The result of a notification event. Nothing to do other than close the
// connection.
close();
return;
}
HttpResponse response = request.response;
// We closed the connection for bad origins earlier.
response.headers.add('Access-Control-Allow-Origin', '*');
response.headers.contentType = jsonContentType;
switch (result.kind) {
case ResponsePayloadKind.String:
response.write(result.payload);
break;
case ResponsePayloadKind.Utf8String:
response.add(result.payload);
break;
case ResponsePayloadKind.Binary:
throw 'Can not handle binary responses';
}
response.close();
close();
}
Map<String, dynamic> toJson() {
final map = super.toJson();
map['type'] = 'HttpRequestClient';
map['request'] = '$request';
return map;
}
}
/// Responsible for launching a DevTools instance when the service is started
/// via SIGQUIT.
class _DebuggingSession {
Future<bool> start(
Uri serverAddress,
String host,
String port,
bool disableServiceAuthCodes,
bool enableDevTools,
) async {
// This code is part of the SDK and it is ok to have a reference to the
// internals of the Dart SDK in terms of location of the snapshot etc.
// It is more efficient doing it this way instead of invoking the Dart CLI
// with the 'development-service' command which would then dispatch to the
// Dart AOT runtime.
final dartDir = File(Platform.executable).parent.path;
final suffix = Platform.isWindows ? '.exe' : '';
final dartAotRuntime = 'dartaotruntime${suffix}';
final dart = 'dart${suffix}';
var executable = [dartDir, dartAotRuntime].join(Platform.pathSeparator);
var script = [
dartDir,
'snapshots',
'dds_aot.dart.snapshot',
].join(Platform.pathSeparator);
if (FileSystemEntity.typeSync(script) == FileSystemEntityType.notFound) {
script = [dartDir, 'dds_aot.dart.snapshot'].join(Platform.pathSeparator);
if (FileSystemEntity.typeSync(script) == FileSystemEntityType.notFound) {
// We could be running on IA32 architecture so check if the JIT
// snapshot is available.
executable = [dartDir, dart].join(Platform.pathSeparator);
script = [dartDir, 'dds.dart.snapshot'].join(Platform.pathSeparator);
if (FileSystemEntity.typeSync(script) ==
FileSystemEntityType.notFound) {
script = 'development-service';
}
}
}
// If the directory of dart is '.' it's likely that dart is on the user's
// PATH. If so, './dart' might not exist and we should be using 'dart'
// instead.
if (dartDir == '.' &&
(FileSystemEntity.typeSync(executable)) ==
FileSystemEntityType.notFound) {
executable = dart;
}
var process = await Process.start(executable, [
script,
'--vm-service-uri=$serverAddress',
'--bind-address=$host',
'--bind-port=$port',
if (disableServiceAuthCodes) '--disable-service-auth-codes',
if (enableDevTools) '--serve-devtools',
if (_enableServicePortFallback) '--enable-service-port-fallback',
], mode: ProcessStartMode.detachedWithStdio);
if (process == null) {
stderr.writeln('Could not start the VM service: Process.start failed\n');
return false;
}
_process = process;
// DDS will close stderr once it's finished launching.
final launchResultStderr = await _process.stderr
.transform(utf8.decoder)
.join();
void printError(String details) =>
stderr.writeln('Could not start the VM service: $details');
try {
final result = json.decode(launchResultStderr) as Map<String, dynamic>;
if (result case {'state': 'started'}) {
if (result case {'devToolsUri': String devToolsUri}) {
// NOTE: update pkg/dartdev/lib/src/commands/run.dart if this message
// is changed to ensure consistency.
const devToolsMessagePrefix =
'The Dart DevTools debugger and profiler is available at:';
serverPrint('$devToolsMessagePrefix $devToolsUri');
}
if (result case {'dtd': {'uri': String dtdUri}} when _printDtd) {
serverPrint('The Dart Tooling Daemon (DTD) is available at: $dtdUri');
}
} else {
printError(result['error'] ?? result);
return false;
}
} catch (_) {
// Malformed JSON was likely encountered, so output the entirety of
// stderr in the error message.
printError("Couldn't parse JSON: ${launchResultStderr}");
return false;
}
return true;
}
void shutdown() => _process.kill();
late Process _process;
}
class Server {
static const WEBSOCKET_PATH = '/ws';
static const ROOT_REDIRECT_PATH = '/index.html';
final VMService _service;
final String _ip;
final bool _originCheckDisabled;
final bool _authCodesDisabled;
final bool _enableServicePortFallback;
final String? _serviceInfoFilename;
HttpServer? _httpServer;
bool get running => _running;
bool _running = false;
bool acceptNewWebSocketConnections = true;
int _port = -1;
// Ensures only one server is started even if many requests to launch
// the server come in concurrently.
Completer<bool>? _startingCompleter;
_DebuggingSession? _ddsInstance;
/// Returns the server address including the auth token.
Uri? get serverAddress {
// If DDS is connected it should be treated as the "true" VM service and be
// advertised as such.
if (_service.ddsUri != null) {
return _service.ddsUri;
}
final server = _httpServer;
if (server != null) {
final ip = server.address.address;
final port = server.port;
final path = !_authCodesDisabled ? '$serviceAuthToken/' : '/';
return Uri(scheme: 'http', host: ip, port: port, path: path);
}
return null;
}
// On Fuchsia, authentication codes are disabled by default. To enable, the authentication token
// would have to be written into the hub alongside the port number.
Server(
this._service,
this._ip,
this._port,
this._originCheckDisabled,
bool authCodesDisabled,
this._serviceInfoFilename,
this._enableServicePortFallback,
) : _authCodesDisabled = (authCodesDisabled || Platform.isFuchsia);
Future<void> startup() async {
if (running) {
// Already running.
return;
}
{
final startingCompleter = _startingCompleter;
if (startingCompleter != null) {
if (!startingCompleter.isCompleted) {
await startingCompleter.future;
}
return;
}
}
final startingCompleter = Completer<bool>();
_startingCompleter = startingCompleter;
// Startup HTTP server.
Future<bool> startServer() async {
try {
var address;
var addresses = await InternetAddress.lookup(_ip);
// Prefer IPv4 addresses.
for (int i = 0; i < addresses.length; i++) {
address = addresses[i];
if (address.type == InternetAddressType.IPv4) break;
}
_httpServer = await HttpServer.bind(address, _port);
} catch (e, st) {
if (_port != 0 && _enableServicePortFallback) {
serverPrint(
'Failed to bind Dart VM service HTTP server to port $_port. '
'Falling back to automatic port selection',
);
_port = 0;
return await startServer();
} else {
serverPrint(
'Could not start Dart VM service HTTP server:\n'
'$e\n$st',
);
_notifyServerState('');
onServerAddressChange(null);
return false;
}
}
return true;
}
if (!(await startServer())) {
startingCompleter.complete(true);
return;
}
if (_service.isExiting) {
serverPrint(
'Dart VM service HTTP server exiting before listening as '
'vm service has received exit request\n',
);
startingCompleter.complete(true);
await shutdown(true);
return;
}
final server = _httpServer!;
server.listen(_requestHandler, cancelOnError: true);
if (_waitForDdsToAdvertiseService) {
_ddsInstance = _DebuggingSession();
await _ddsInstance!.start(
serverAddress!,
_ddsIP,
_ddsPort.toString(),
_authCodesDisabled,
_serveDevtools,
);
} else {
await outputConnectionInformation();
}
// Server is up and running.
_running = true;
_notifyServerState(serverAddress.toString());
onServerAddressChange('$serverAddress');
startingCompleter.complete(true);
}
Future<void> shutdown(bool forced) async {
// If start is pending, wait for it to complete.
if (_startingCompleter != null) {
if (!_startingCompleter!.isCompleted) {
await _startingCompleter!.future;
}
}
final server = _httpServer;
if (server == null) {
// Not started.
return;
}
if (Platform.isFuchsia) {
_cleanupFuchsiaState(server.port);
}
final address = serverAddress!;
try {
// Shutdown HTTP server and subscription.
await server.close(force: forced);
if (!_service.isExiting) {
// Only print this message if the service has been toggled off, not
// when the VM is exiting.
serverPrint('Dart VM service no longer listening on $address');
}
} catch (e, st) {
serverPrint('Could not shutdown Dart VM service HTTP server:\n$e\n$st\n');
} finally {
_ddsInstance?.shutdown();
_ddsInstance = null;
_httpServer = null;
_startingCompleter = null;
_running = false;
_notifyServerState('');
onServerAddressChange(null);
}
}
Future<void> outputConnectionInformation() async {
serverPrint('The Dart VM service is listening on $serverAddress');
if (Platform.isFuchsia) {
_writeFuchsiaState(_httpServer!.port);
}
final serviceInfoFilenameLocal = _serviceInfoFilename;
if (serviceInfoFilenameLocal != null &&
serviceInfoFilenameLocal.isNotEmpty) {
await _dumpServiceInfoToFile(serviceInfoFilenameLocal);
}
}
bool _isAllowedOrigin(String origin) {
Uri uri;
try {
uri = Uri.parse(origin);
} catch (_) {
return false;
}
// Explicitly add localhost and 127.0.0.1 on any port (necessary for
// adb port forwarding).
if ((uri.host == 'localhost') ||
(uri.host == '::1') ||
(uri.host == '127.0.0.1')) {
return true;
}
final server = _httpServer!;
if ((uri.port == server.port) &&
((uri.host == server.address.address) ||
(uri.host == server.address.host))) {
return true;
}
return false;
}
bool _originCheck(HttpRequest request) {
if (_originCheckDisabled) {
// Always allow.
return true;
}
// First check the web-socket specific origin.
List<String>? origins = request.headers['Sec-WebSocket-Origin'];
if (origins == null) {
// Fall back to the general Origin field.
origins = request.headers['Origin'];
}
if (origins == null) {
// No origin sent. This is a non-browser client or a same-origin request.
return true;
}
for (final origin in origins) {
if (_isAllowedOrigin(origin)) {
return true;
}
}
return false;
}
/// Checks the [requestUri] for the service auth token and returns the path
/// as a String. If the service auth token check fails, returns null.
/// Returns a Uri if a redirect is required.
dynamic _checkAuthTokenAndGetPath(Uri requestUri) {
if (_authCodesDisabled) {
return requestUri.path == '/' ? ROOT_REDIRECT_PATH : requestUri.path;
}
final List<String> requestPathSegments = requestUri.pathSegments;
if (requestPathSegments.isEmpty) {
// Malformed.
return null;
}
// Check that we were given the auth token.
final authToken = requestPathSegments[0];
if (authToken != serviceAuthToken) {
// Malformed.
return null;
}
// Missing a trailing '/'. We'll need to redirect to serve
// ROOT_REDIRECT_PATH correctly, otherwise the response is misinterpreted.
if (requestPathSegments.length == 1) {
// requestPathSegments is unmodifiable. Copy it.
final pathSegments = List<String>.from(requestPathSegments);
// Adding an empty string to the path segments results in the path having
// a trailing '/'.
pathSegments.add('');
return requestUri.replace(pathSegments: pathSegments);
}
// Construct the actual request path by chopping off the auth token.
return (requestPathSegments[1] == '')
? ROOT_REDIRECT_PATH
: '/${requestPathSegments.sublist(1).join('/')}';
}
Future<void> _processDevFSRequest(HttpRequest request) async {
String? fsName;
String? fsPath;
Uri? fsUri;
try {
// Extract the fs name and fs path from the request headers.
fsName = request.headers['dev_fs_name']![0];
// Prefer Uri encoding first, then fallback to path encoding.
if (request.headers['dev_fs_uri_b64'] case [String base64Uri]) {
fsUri = Uri.parse(utf8.decode(base64.decode(base64Uri)));
} else if (request.headers['dev_fs_path_b64'] case [String base64Uri]) {
fsPath = utf8.decode(base64.decode(base64Uri));
} else if (request.headers['dev_fs_path'] case [String path]) {
fsPath = path;
}
} catch (_) {
/* ignore */
}
try {
final result = await _service.devfs.handlePutStream(
fsName,
fsPath,
fsUri,
request.cast<List<int>>().transform(gzip.decoder),
);
request.response.headers.contentType = HttpRequestClient.jsonContentType;
request.response.write(result);
} catch (e, st) {
request.response.statusCode = HttpStatus.internalServerError;
request.response.write(e);
} finally {
request.response.close();
}
}
void _handleWebSocketRequest(HttpRequest request) {
final subprotocols = request.headers['sec-websocket-protocol'];
if (acceptNewWebSocketConnections) {
WebSocketTransformer.upgrade(
request,
protocolSelector: subprotocols == null
? null
: (_) => 'implicit-redirect',
compression: CompressionOptions.compressionOff,
).then((WebSocket webSocket) {
WebSocketClient(webSocket, _service);
});
} else {
// Attempt to redirect client to the DDS instance.
request.response.redirect(_service.ddsUri!);
}
}
Future<void> _redirectToDevTools(HttpRequest request) async {
final ddsUri = _service.ddsUri;
if (ddsUri == null) {
request.response.headers.contentType = ContentType.text;
request.response.write(
'This VM does not have a registered Dart '
'Development Service (DDS) instance and is not currently serving '
'Dart DevTools.',
);
request.response.close();
return;
}
// We build this path manually rather than manipulating ddsUri directly
// as the resulting path requires an unencoded '#'. The Uri class will
// always encode '#' as '%23' in paths to avoid conflicts with fragments,
// which will result in the redirect failing.
final path = StringBuffer();
// Add authentication code to the path.
if (ddsUri.pathSegments.length > 1) {
path.writeAll([
ddsUri.pathSegments
.sublist(0, ddsUri.pathSegments.length - 1)
.join('/'),
'/',
]);
}
final queryComponent = Uri.encodeQueryComponent(
ddsUri.replace(scheme: 'ws', path: '${path}ws').toString(),
);
path.writeAll(['devtools/', '?uri=$queryComponent']);
final redirectUri = Uri.parse('http://${ddsUri.host}:${ddsUri.port}/$path');
request.response.redirect(redirectUri);
return;
}
Future<void> _requestHandler(HttpRequest request) async {
if (!_originCheck(request)) {
// This is a cross origin attempt to connect
request.response.statusCode = HttpStatus.forbidden;
request.response.write('forbidden origin');
request.response.close();
return;
}
if (request.method == 'PUT') {
// PUT requests are forwarded to DevFS for processing.
await _processDevFSRequest(request);
return;
}
if (request.method != 'GET') {
// Not a GET request. Do nothing.
request.response.statusCode = HttpStatus.methodNotAllowed;
request.response.write('method not allowed');
request.response.close();
return;
}
final result = _checkAuthTokenAndGetPath(request.uri);
if (result == null) {
// Either no authentication code was provided when one was expected or an
// incorrect authentication code was provided.
request.response.statusCode = HttpStatus.forbidden;
request.response.write('missing or invalid authentication code');
request.response.close();
return;
} else if (result is Uri) {
// The URI contains the valid auth token but is missing a trailing '/'.
// Redirect to the same URI with the trailing '/' to correctly serve
// index.html.
request.response.redirect(result);
return;
}
final String path = result;
if (path == WEBSOCKET_PATH) {
_handleWebSocketRequest(request);
return;
}
// Don't redirect HTTP VM service requests, just requests for DevTools
// assets.
if (path == ROOT_REDIRECT_PATH) {
await _redirectToDevTools(request);
return;
}
// HTTP based service request.
final client = HttpRequestClient(request, _service);
final message = Message.fromUri(
client,
Uri(path: path, queryParameters: request.uri.queryParameters),
);
client.onRequest(message); // exception free, no need to try catch
}
Future<File> _dumpServiceInfoToFile(String serviceInfoFilenameLocal) async {
final serviceInfo = <String, dynamic>{'uri': serverAddress.toString()};
const kFileScheme = 'file://';
// There's lots of URI parsing weirdness as Uri.parse doesn't do the right
// thing with Windows drive letters. Only use Uri.parse with known file
// URIs, and use Uri.file otherwise to properly handle drive letters in
// paths.
final uri = serviceInfoFilenameLocal.startsWith(kFileScheme)
? Uri.parse(serviceInfoFilenameLocal)
: Uri.file(serviceInfoFilenameLocal);
final file = File.fromUri(uri);
return file.writeAsString(json.encode(serviceInfo));
}
void _writeFuchsiaState(int port) {
// Create a file with the port number.
final tmp = Directory.systemTemp.path;
final path = '$tmp/dart.services/${port}';
serverPrint('Creating $path');
File(path).createSync(recursive: true);
}
void _cleanupFuchsiaState(int port) {
// Remove the file with the port number.
final tmp = Directory.systemTemp.path;
final path = '$tmp/dart.services/$port';
serverPrint('Deleting $path');
File(path).deleteSync();
}
}
@pragma("vm:external-name", "VMServiceIO_NotifyServerState")
external void _notifyServerState(String uri);