// 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 vmservice_io; final bool silentObservatory = new bool.fromEnvironment('SILENT_OBSERVATORY'); void serverPrint(String s) { if (silentObservatory) { // We've been requested to be silent. return; } print(s); } class WebSocketClient extends Client { static const int PARSE_ERROR_CODE = 4000; static const int BINARY_MESSAGE_ERROR_CODE = 4001; static const int NOT_MAP_ERROR_CODE = 4002; static const int ID_ERROR_CODE = 4003; final WebSocket socket; WebSocketClient(this.socket, VMService service) : super(service) { socket.listen((message) => onWebSocketMessage(message)); socket.done.then((_) => close()); } disconnect() { if (socket != null) { socket.close(); } } void onWebSocketMessage(message) { if (message is String) { var map; try { map = json.decode(message); } catch (e) { socket.close(PARSE_ERROR_CODE, 'Message parse error: $e'); return; } if (map is! Map) { socket.close(NOT_MAP_ERROR_CODE, 'Message must be a JSON map.'); return; } try { final rpc = new 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; } } catch (e) { socket.close(ID_ERROR_CODE, e.message); } } else { socket.close(BINARY_MESSAGE_ERROR_CODE, 'Message must be a string.'); } } void post(Response result) { if (result == null) { // 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); break; } } on StateError catch (_) { // VM has shutdown, do nothing. return; } } dynamic toJson() { Map map = super.toJson(); map['type'] = 'WebSocketClient'; map['socket'] = '$socket'; return map; } } class HttpRequestClient extends Client { static ContentType jsonContentType = new ContentType("application", "json", charset: "utf-8"); final HttpRequest request; HttpRequestClient(this.request, VMService service) : super(service, sendEvents: false); disconnect() { request.response.close(); close(); } void post(Response result) { if (result == null) { 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(); } dynamic toJson() { Map map = super.toJson(); map['type'] = 'HttpRequestClient'; map['request'] = '$request'; return map; } } class Server { static const WEBSOCKET_PATH = '/ws'; static const ROOT_REDIRECT_PATH = '/index.html'; final VMService _service; final String _ip; final int _port; final bool _originCheckDisabled; final bool _authCodesDisabled; HttpServer _server; bool get running => _server != null; /// Returns the server address including the auth token. Uri get serverAddress { if (!running) { return null; } var ip = _server.address.address; var port = _server.port; var path = !_authCodesDisabled ? "$serviceAuthToken/" : "/"; return new Uri(scheme: 'http', host: ip, port: port, path: path); } // 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) : _authCodesDisabled = (authCodesDisabled || Platform.isFuchsia); 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; } 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 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 (String 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 requestPathSegments = requestUri.pathSegments; if (requestPathSegments.isEmpty) { // Malformed. return null; } // Check that we were given the auth token. final String 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 List pathSegments = []..addAll(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 _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. List fsNameList; List fsPathList; List fsPathBase64List; List fsUriBase64List; Object fsName; Object fsPath; Object fsUri; try { // Extract the fs name and fs path from the request headers. fsNameList = request.headers['dev_fs_name']; fsName = fsNameList[0]; // Prefer Uri encoding first. fsUriBase64List = request.headers['dev_fs_uri_b64']; if ((fsUriBase64List != null) && (fsUriBase64List.length > 0)) { String decodedFsUri = utf8.decode(base64.decode(fsUriBase64List[0])); fsUri = Uri.parse(decodedFsUri); } // Fallback to path encoding. if (fsUri == null) { fsPathList = request.headers['dev_fs_path']; fsPathBase64List = request.headers['dev_fs_path_b64']; // If the 'dev_fs_path_b64' header field was sent, use that instead. if ((fsPathBase64List != null) && (fsPathBase64List.length > 0)) { fsPath = utf8.decode(base64.decode(fsPathBase64List[0])); } else { fsPath = fsPathList[0]; } } } catch (e) {/* ignore */} String result; try { result = await _service.devfs.handlePutStream( fsName, fsPath, fsUri, request.transform(GZIP.decoder)); } catch (e) { request.response.statusCode = HttpStatus.internalServerError; request.response.write(e); request.response.close(); return; } if (result != null) { request.response.headers.contentType = HttpRequestClient.jsonContentType; request.response.write(result); } request.response.close(); 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 dynamic 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 as Uri); request.response.close(); return; } final String path = result; if (path == WEBSOCKET_PATH) { WebSocketTransformer.upgrade(request).then((WebSocket webSocket) { new WebSocketClient(webSocket, _service); }); return; } if (assets == null) { request.response.headers.contentType = ContentType.TEXT; request.response.write("This VM was built without the Observatory UI."); request.response.close(); return; } Asset asset = assets[path]; if (asset != null) { // Serving up a static asset (e.g. .css, .html, .png). request.response.headers.contentType = ContentType.parse(asset.mimeType); request.response.add(asset.data); request.response.close(); return; } // HTTP based service request. final client = new HttpRequestClient(request, _service); final message = new Message.fromUri( client, Uri(path: path, queryParameters: request.uri.queryParameters)); client.onRequest(message); // exception free, no need to try catch } Future startup() async { if (_server != null) { // Already running. return this; } // Startup HTTP server. var pollError; var pollStack; Future poll() async { try { var address; var addresses = await InternetAddress.lookup(_ip); // Prefer IPv4 addresses. for (var i = 0; i < addresses.length; i++) { address = addresses[i]; if (address.type == InternetAddressType.IP_V4) break; } _server = await HttpServer.bind(address, _port); return true; } catch (e, st) { pollError = e; pollStack = st; return false; } } // poll for the network for ~10 seconds. int attempts = 0; final int maxAttempts = 10; while (!await poll()) { attempts++; serverPrint("Observatory server failed to start after $attempts tries"); if (attempts > maxAttempts) { serverPrint('Could not start Observatory HTTP server:\n' '$pollError\n$pollStack\n'); _notifyServerState(""); onServerAddressChange(null); return this; } await new Future.delayed(const Duration(seconds: 1)); } if (_service.isExiting) { serverPrint('Observatory HTTP server exiting before listening as ' 'vm service has received exit request\n'); await shutdown(true); return this; } _server.listen(_requestHandler, cancelOnError: true); serverPrint('Observatory listening on $serverAddress'); if (Platform.isFuchsia) { // Create a file with the port number. String tmp = Directory.systemTemp.path; String path = "$tmp/dart.services/${_server.port}"; serverPrint("Creating $path"); new File(path)..createSync(recursive: true); } // Server is up and running. _notifyServerState(serverAddress.toString()); onServerAddressChange('$serverAddress'); return this; } Future cleanup(bool force) { if (_server == null) { return new Future.value(null); } if (Platform.isFuchsia) { // Remove the file with the port number. String tmp = Directory.systemTemp.path; String path = "$tmp/dart.services/${_server.port}"; serverPrint("Deleting $path"); new File(path)..deleteSync(); } return _server.close(force: force); } Future shutdown(bool forced) { if (_server == null) { // Not started. return new Future.value(this); } // Shutdown HTTP server and subscription. Uri oldServerAddress = serverAddress; return cleanup(forced).then((_) { serverPrint('Observatory no longer listening on $oldServerAddress'); _server = null; _notifyServerState(""); onServerAddressChange(null); return this; }).catchError((e, st) { _server = null; serverPrint('Could not shutdown Observatory HTTP server:\n$e\n$st\n'); _notifyServerState(""); onServerAddressChange(null); return this; }); } } void _notifyServerState(String uri) native "VMServiceIO_NotifyServerState";