/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; var { Ci, Cc, CC, Cr, Cu } = require("chrome"); // Ensure PSM is initialized to support TLS sockets Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports); var Services = require("Services"); var promise = require("promise"); var defer = require("devtools/shared/defer"); var DevToolsUtils = require("devtools/shared/DevToolsUtils"); var { dumpn, dumpv } = DevToolsUtils; loader.lazyRequireGetter(this, "WebSocketServer", "devtools/server/websocket-server"); loader.lazyRequireGetter(this, "DebuggerTransport", "devtools/shared/transport/transport", true); loader.lazyRequireGetter(this, "WebSocketDebuggerTransport", "devtools/shared/transport/websocket-transport"); loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true); loader.lazyRequireGetter(this, "discovery", "devtools/shared/discovery/discovery"); loader.lazyRequireGetter(this, "cert", "devtools/shared/security/cert"); loader.lazyRequireGetter(this, "Authenticators", "devtools/shared/security/auth", true); loader.lazyRequireGetter(this, "AuthenticationResult", "devtools/shared/security/auth", true); DevToolsUtils.defineLazyGetter(this, "nsFile", () => { return CC("@mozilla.org/file/local;1", "nsIFile", "initWithPath"); }); DevToolsUtils.defineLazyGetter(this, "socketTransportService", () => { return Cc["@mozilla.org/network/socket-transport-service;1"] .getService(Ci.nsISocketTransportService); }); DevToolsUtils.defineLazyGetter(this, "certOverrideService", () => { return Cc["@mozilla.org/security/certoverride;1"] .getService(Ci.nsICertOverrideService); }); DevToolsUtils.defineLazyGetter(this, "nssErrorsService", () => { return Cc["@mozilla.org/nss_errors_service;1"] .getService(Ci.nsINSSErrorsService); }); const { Task } = require("devtools/shared/task"); var DebuggerSocket = {}; /** * Connects to a debugger server socket. * * @param host string * The host name or IP address of the debugger server. * @param port number * The port number of the debugger server. * @param encryption boolean (optional) * Whether the server requires encryption. Defaults to false. * @param webSocket boolean (optional) * Whether to use WebSocket protocol to connect. Defaults to false. * @param authenticator Authenticator (optional) * |Authenticator| instance matching the mode in use by the server. * Defaults to a PROMPT instance if not supplied. * @param cert object (optional) * The server's cert details. Used with OOB_CERT authentication. * @return promise * Resolved to a DebuggerTransport instance. */ DebuggerSocket.connect = Task.async(function* (settings) { // Default to PROMPT |Authenticator| instance if not supplied if (!settings.authenticator) { settings.authenticator = new (Authenticators.get().Client)(); } _validateSettings(settings); let { host, port, encryption, authenticator, cert } = settings; let transport = yield _getTransport(settings); yield authenticator.authenticate({ host, port, encryption, cert, transport }); transport.connectionSettings = settings; return transport; }); /** * Validate that the connection settings have been set to a supported configuration. */ function _validateSettings(settings) { let { encryption, webSocket, authenticator } = settings; if (webSocket && encryption) { throw new Error("Encryption not supported on WebSocket transport"); } authenticator.validateSettings(settings); } /** * Try very hard to create a DevTools transport, potentially making several * connect attempts in the process. * * @param host string * The host name or IP address of the debugger server. * @param port number * The port number of the debugger server. * @param encryption boolean (optional) * Whether the server requires encryption. Defaults to false. * @param webSocket boolean (optional) * Whether to use WebSocket protocol to connect to the server. Defaults to false. * @param authenticator Authenticator * |Authenticator| instance matching the mode in use by the server. * Defaults to a PROMPT instance if not supplied. * @param cert object (optional) * The server's cert details. Used with OOB_CERT authentication. * @return transport DebuggerTransport * A possible DevTools transport (if connection succeeded and streams * are actually alive and working) */ var _getTransport = Task.async(function* (settings) { let { host, port, encryption, webSocket } = settings; if (webSocket) { // Establish a connection and wait until the WebSocket is ready to send and receive let socket = yield new Promise((resolve, reject) => { let s = new WebSocket(`ws://${host}:${port}`); s.onopen = () => resolve(s); s.onerror = err => reject(err); }); return new WebSocketDebuggerTransport(socket); } let attempt = yield _attemptTransport(settings); if (attempt.transport) { return attempt.transport; // Success } // If the server cert failed validation, store a temporary override and make // a second attempt. if (encryption && attempt.certError) { _storeCertOverride(attempt.s, host, port); } else { throw new Error("Connection failed"); } attempt = yield _attemptTransport(settings); if (attempt.transport) { return attempt.transport; // Success } throw new Error("Connection failed even after cert override"); }); /** * Make a single attempt to connect and create a DevTools transport. This could * fail if the remote host is unreachable, for example. If there is security * error due to the use of self-signed certs, you should make another attempt * after storing a cert override. * * @param host string * The host name or IP address of the debugger server. * @param port number * The port number of the debugger server. * @param encryption boolean (optional) * Whether the server requires encryption. Defaults to false. * @param authenticator Authenticator * |Authenticator| instance matching the mode in use by the server. * Defaults to a PROMPT instance if not supplied. * @param cert object (optional) * The server's cert details. Used with OOB_CERT authentication. * @return transport DebuggerTransport * A possible DevTools transport (if connection succeeded and streams * are actually alive and working) * @return certError boolean * Flag noting if cert trouble caused the streams to fail * @return s nsISocketTransport * Underlying socket transport, in case more details are needed. */ var _attemptTransport = Task.async(function* (settings) { let { authenticator } = settings; // _attemptConnect only opens the streams. Any failures at that stage // aborts the connection process immedidately. let { s, input, output } = yield _attemptConnect(settings); // Check if the input stream is alive. If encryption is enabled, we need to // watch out for cert errors by testing the input stream. let alive, certError; try { let results = yield _isInputAlive(input); alive = results.alive; certError = results.certError; } catch (e) { // For other unexpected errors, like NS_ERROR_CONNECTION_REFUSED, we reach // this block. input.close(); output.close(); throw e; } dumpv("Server cert accepted? " + !certError); // The |Authenticator| examines the connection as well and may determine it // should be dropped. alive = alive && authenticator.validateConnection({ host: settings.host, port: settings.port, encryption: settings.encryption, cert: settings.cert, socket: s }); let transport; if (alive) { transport = new DebuggerTransport(input, output); } else { // Something went wrong, close the streams. input.close(); output.close(); } return { transport, certError, s }; }); /** * Try to connect to a remote server socket. * * If successsful, the socket transport and its opened streams are returned. * Typically, this will only fail if the host / port is unreachable. Other * problems, such as security errors, will allow this stage to succeed, but then * fail later when the streams are actually used. * @return s nsISocketTransport * Underlying socket transport, in case more details are needed. * @return input nsIAsyncInputStream * The socket's input stream. * @return output nsIAsyncOutputStream * The socket's output stream. */ var _attemptConnect = Task.async(function* ({ host, port, encryption }) { let s; if (encryption) { s = socketTransportService.createTransport(["ssl"], 1, host, port, null); } else { s = socketTransportService.createTransport(null, 0, host, port, null); } // By default the CONNECT socket timeout is very long, 65535 seconds, // so that if we race to be in CONNECT state while the server socket is still // initializing, the connection is stuck in connecting state for 18.20 hours! s.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, 2); // If encrypting, load the client cert now, so we can deliver it at just the // right time. let clientCert; if (encryption) { clientCert = yield cert.local.getOrCreate(); } let deferred = defer(); let input; let output; // Delay opening the input stream until the transport has fully connected. // The goal is to avoid showing the user a client cert UI prompt when // encryption is used. This prompt is shown when the client opens the input // stream and does not know which client cert to present to the server. To // specify a client cert programmatically, we need to access the transport's // nsISSLSocketControl interface, which is not accessible until the transport // has connected. s.setEventSink({ onTransportStatus(transport, status) { if (status != Ci.nsISocketTransport.STATUS_CONNECTING_TO) { return; } if (encryption) { let sslSocketControl = transport.securityInfo.QueryInterface(Ci.nsISSLSocketControl); sslSocketControl.clientCert = clientCert; } try { input = s.openInputStream(0, 0, 0); } catch (e) { deferred.reject(e); } deferred.resolve({ s, input, output }); } }, Services.tm.currentThread); // openOutputStream may throw NS_ERROR_NOT_INITIALIZED if we hit some race // where the nsISocketTransport gets shutdown in between its instantiation and // the call to this method. try { output = s.openOutputStream(0, 0, 0); } catch (e) { deferred.reject(e); } deferred.promise.catch(e => { if (input) { input.close(); } if (output) { output.close(); } DevToolsUtils.reportException("_attemptConnect", e); }); return deferred.promise; }); /** * Check if the input stream is alive. For an encrypted connection, it may not * be if the client refuses the server's cert. A cert error is expected on * first connection to a new host because the cert is self-signed. */ function _isInputAlive(input) { let deferred = defer(); input.asyncWait({ onInputStreamReady(stream) { try { stream.available(); deferred.resolve({ alive: true }); } catch (e) { try { // getErrorClass may throw if you pass a non-NSS error let errorClass = nssErrorsService.getErrorClass(e.result); if (errorClass === Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { deferred.resolve({ certError: true }); } else { deferred.reject(e); } } catch (nssErr) { deferred.reject(e); } } } }, 0, 0, Services.tm.currentThread); return deferred.promise; } /** * To allow the connection to proceed with self-signed cert, we store a cert * override. This implies that we take on the burden of authentication for * these connections. */ function _storeCertOverride(s, host, port) { let cert = s.securityInfo.QueryInterface(Ci.nsISSLStatusProvider) .SSLStatus.serverCert; let overrideBits = Ci.nsICertOverrideService.ERROR_UNTRUSTED | Ci.nsICertOverrideService.ERROR_MISMATCH; certOverrideService.rememberValidityOverride(host, port, cert, overrideBits, true /* temporary */); } /** * Creates a new socket listener for remote connections to the DebuggerServer. * This helps contain and organize the parts of the server that may differ or * are particular to one given listener mechanism vs. another. */ function SocketListener() {} SocketListener.prototype = { /* Socket Options */ /** * The port or path to listen on. * * If given an integer, the port to listen on. Use -1 to choose any available * port. Otherwise, the path to the unix socket domain file to listen on. */ portOrPath: null, /** * Controls whether this listener is announced via the service discovery * mechanism. */ discoverable: false, /** * Controls whether this listener's transport uses encryption. */ encryption: false, /** * Controls the |Authenticator| used, which hooks various socket steps to * implement an authentication policy. It is expected that different use * cases may override pieces of the |Authenticator|. See auth.js. * * Here we set the default |Authenticator|, which is |Prompt|. */ authenticator: new (Authenticators.get().Server)(), /** * Validate that all options have been set to a supported configuration. */ _validateOptions: function () { if (this.portOrPath === null) { throw new Error("Must set a port / path to listen on."); } if (this.discoverable && !Number(this.portOrPath)) { throw new Error("Discovery only supported for TCP sockets."); } if (this.encryption && this.webSocket) { throw new Error("Encryption not supported on WebSocket transport"); } this.authenticator.validateOptions(this); }, /** * Listens on the given port or socket file for remote debugger connections. */ open: function () { this._validateOptions(); DebuggerServer._addListener(this); let flags = Ci.nsIServerSocket.KeepWhenOffline; // A preference setting can force binding on the loopback interface. if (Services.prefs.getBoolPref("devtools.debugger.force-local")) { flags |= Ci.nsIServerSocket.LoopbackOnly; } let self = this; return Task.spawn(function* () { let backlog = 4; self._socket = self._createSocketInstance(); if (self.isPortBased) { let port = Number(self.portOrPath); self._socket.initSpecialConnection(port, flags, backlog); } else { let file = nsFile(self.portOrPath); if (file.exists()) { file.remove(false); } self._socket.initWithFilename(file, parseInt("666", 8), backlog); } yield self._setAdditionalSocketOptions(); self._socket.asyncListen(self); dumpn("Socket listening on: " + (self.port || self.portOrPath)); }).then(() => { this._advertise(); }).catch(e => { dumpn("Could not start debugging listener on '" + this.portOrPath + "': " + e); this.close(); }); }, _advertise: function () { if (!this.discoverable || !this.port) { return; } let advertisement = { port: this.port, encryption: this.encryption, }; this.authenticator.augmentAdvertisement(this, advertisement); discovery.addService("devtools", advertisement); }, _createSocketInstance: function () { if (this.encryption) { return Cc["@mozilla.org/network/tls-server-socket;1"] .createInstance(Ci.nsITLSServerSocket); } return Cc["@mozilla.org/network/server-socket;1"] .createInstance(Ci.nsIServerSocket); }, _setAdditionalSocketOptions: Task.async(function* () { if (this.encryption) { this._socket.serverCert = yield cert.local.getOrCreate(); this._socket.setSessionTickets(false); let requestCert = Ci.nsITLSServerSocket.REQUEST_NEVER; this._socket.setRequestClientCertificate(requestCert); } this.authenticator.augmentSocketOptions(this, this._socket); }), /** * Closes the SocketListener. Notifies the server to remove the listener from * the set of active SocketListeners. */ close: function () { if (this.discoverable && this.port) { discovery.removeService("devtools"); } if (this._socket) { this._socket.close(); this._socket = null; } DebuggerServer._removeListener(this); }, get host() { if (!this._socket) { return null; } if (Services.prefs.getBoolPref("devtools.debugger.force-local")) { return "127.0.0.1"; } return "0.0.0.0"; }, /** * Gets whether this listener uses a port number vs. a path. */ get isPortBased() { return !!Number(this.portOrPath); }, /** * Gets the port that a TCP socket listener is listening on, or null if this * is not a TCP socket (so there is no port). */ get port() { if (!this.isPortBased || !this._socket) { return null; } return this._socket.port; }, get cert() { if (!this._socket || !this._socket.serverCert) { return null; } return { sha256: this._socket.serverCert.sha256Fingerprint }; }, // nsIServerSocketListener implementation onSocketAccepted: DevToolsUtils.makeInfallible(function (socket, socketTransport) { new ServerSocketConnection(this, socketTransport); }, "SocketListener.onSocketAccepted"), onStopListening: function (socket, status) { dumpn("onStopListening, status: " + status); } }; // Client must complete TLS handshake within this window (ms) loader.lazyGetter(this, "HANDSHAKE_TIMEOUT", () => { return Services.prefs.getIntPref("devtools.remote.tls-handshake-timeout"); }); /** * A |ServerSocketConnection| is created by a |SocketListener| for each accepted * incoming socket. This is a short-lived object used to implement * authentication and verify encryption prior to handing off the connection to * the |DebuggerServer|. */ function ServerSocketConnection(listener, socketTransport) { this._listener = listener; this._socketTransport = socketTransport; this._handle(); } ServerSocketConnection.prototype = { get authentication() { return this._listener.authenticator.mode; }, get host() { return this._socketTransport.host; }, get port() { return this._socketTransport.port; }, get cert() { if (!this._clientCert) { return null; } return { sha256: this._clientCert.sha256Fingerprint }; }, get address() { return this.host + ":" + this.port; }, get client() { let client = { host: this.host, port: this.port }; if (this.cert) { client.cert = this.cert; } return client; }, get server() { let server = { host: this._listener.host, port: this._listener.port }; if (this._listener.cert) { server.cert = this._listener.cert; } return server; }, /** * This is the main authentication workflow. If any pieces reject a promise, * the connection is denied. If the entire process resolves successfully, * the connection is finally handed off to the |DebuggerServer|. */ _handle() { dumpn("Debugging connection starting authentication on " + this.address); let self = this; Task.spawn(function* () { self._listenForTLSHandshake(); yield self._createTransport(); yield self._awaitTLSHandshake(); yield self._authenticate(); }).then(() => this.allow()).catch(e => this.deny(e)); }, /** * We need to open the streams early on, as that is required in the case of * TLS sockets to keep the handshake moving. */ _createTransport: Task.async(function* () { let input = this._socketTransport.openInputStream(0, 0, 0); let output = this._socketTransport.openOutputStream(0, 0, 0); if (this._listener.webSocket) { let socket = yield WebSocketServer.accept(this._socketTransport, input, output); this._transport = new WebSocketDebuggerTransport(socket); } else { this._transport = new DebuggerTransport(input, output); } // Start up the transport to observe the streams in case they are closed // early. This allows us to clean up our state as well. this._transport.hooks = { onClosed: reason => { this.deny(reason); } }; this._transport.ready(); }), /** * Set the socket's security observer, which receives an event via the * |onHandshakeDone| callback when the TLS handshake completes. */ _setSecurityObserver(observer) { if (!this._socketTransport || !this._socketTransport.securityInfo) { return; } let connectionInfo = this._socketTransport.securityInfo .QueryInterface(Ci.nsITLSServerConnectionInfo); connectionInfo.setSecurityObserver(observer); }, /** * When encryption is used, we wait for the client to complete the TLS * handshake before proceeding. The handshake details are validated in * |onHandshakeDone|. */ _listenForTLSHandshake() { this._handshakeDeferred = defer(); if (!this._listener.encryption) { this._handshakeDeferred.resolve(); return; } this._setSecurityObserver(this); this._handshakeTimeout = setTimeout(this._onHandshakeTimeout.bind(this), HANDSHAKE_TIMEOUT); }, _awaitTLSHandshake() { return this._handshakeDeferred.promise; }, _onHandshakeTimeout() { dumpv("Client failed to complete TLS handshake"); this._handshakeDeferred.reject(Cr.NS_ERROR_NET_TIMEOUT); }, // nsITLSServerSecurityObserver implementation onHandshakeDone(socket, clientStatus) { clearTimeout(this._handshakeTimeout); this._setSecurityObserver(null); dumpv("TLS version: " + clientStatus.tlsVersionUsed.toString(16)); dumpv("TLS cipher: " + clientStatus.cipherName); dumpv("TLS key length: " + clientStatus.keyLength); dumpv("TLS MAC length: " + clientStatus.macLength); this._clientCert = clientStatus.peerCert; /* * TODO: These rules should be really be set on the TLS socket directly, but * this would need more platform work to expose it via XPCOM. * * Enforcing cipher suites here would be a bad idea, as we want TLS * cipher negotiation to work correctly. The server already allows only * Gecko's normal set of cipher suites. */ if (clientStatus.tlsVersionUsed < Ci.nsITLSClientStatus.TLS_VERSION_1_2) { this._handshakeDeferred.reject(Cr.NS_ERROR_CONNECTION_REFUSED); return; } this._handshakeDeferred.resolve(); }, _authenticate: Task.async(function* () { let result = yield this._listener.authenticator.authenticate({ client: this.client, server: this.server, transport: this._transport }); switch (result) { case AuthenticationResult.DISABLE_ALL: DebuggerServer.closeAllListeners(); Services.prefs.setBoolPref("devtools.debugger.remote-enabled", false); return promise.reject(Cr.NS_ERROR_CONNECTION_REFUSED); case AuthenticationResult.DENY: return promise.reject(Cr.NS_ERROR_CONNECTION_REFUSED); case AuthenticationResult.ALLOW: case AuthenticationResult.ALLOW_PERSIST: return promise.resolve(); default: return promise.reject(Cr.NS_ERROR_CONNECTION_REFUSED); } }), deny(result) { if (this._destroyed) { return; } let errorName = result; for (let name in Cr) { if (Cr[name] === result) { errorName = name; break; } } dumpn("Debugging connection denied on " + this.address + " (" + errorName + ")"); if (this._transport) { this._transport.hooks = null; this._transport.close(result); } this._socketTransport.close(result); this.destroy(); }, allow() { if (this._destroyed) { return; } dumpn("Debugging connection allowed on " + this.address); DebuggerServer._onConnection(this._transport); this.destroy(); }, destroy() { this._destroyed = true; clearTimeout(this._handshakeTimeout); this._setSecurityObserver(null); this._listener = null; this._socketTransport = null; this._transport = null; this._clientCert = null; } }; DebuggerSocket.createListener = function () { return new SocketListener(); }; exports.DebuggerSocket = DebuggerSocket;