/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CDP: "chrome://remote/content/cdp/CDP.sys.mjs", Deferred: "chrome://remote/content/shared/Sync.sys.mjs", HttpServer: "chrome://remote/content/server/httpd.sys.mjs", Log: "chrome://remote/content/shared/Log.sys.mjs", WebDriverBiDi: "chrome://remote/content/webdriver-bidi/WebDriverBiDi.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); ChromeUtils.defineLazyGetter(lazy, "activeProtocols", () => { const protocols = Services.prefs.getIntPref("remote.active-protocols"); if (protocols < 1 || protocols > 3) { throw Error(`Invalid remote protocol identifier: ${protocols}`); } return protocols; }); const WEBDRIVER_BIDI_ACTIVE = 0x1; const CDP_ACTIVE = 0x2; const DEFAULT_HOST = "localhost"; const DEFAULT_PORT = 9222; const isRemote = Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; class RemoteAgentParentProcess { #allowHosts; #allowOrigins; #browserStartupFinished; #classID; #enabled; #host; #port; #server; #cdp; #webDriverBiDi; constructor() { this.#allowHosts = null; this.#allowOrigins = null; this.#browserStartupFinished = lazy.Deferred(); this.#classID = Components.ID("{8f685a9d-8181-46d6-a71d-869289099c6d}"); this.#enabled = false; // Configuration for httpd.js this.#host = DEFAULT_HOST; this.#port = DEFAULT_PORT; this.#server = null; // Supported protocols this.#cdp = null; this.#webDriverBiDi = null; Services.ppmm.addMessageListener("RemoteAgent:IsRunning", this); } get allowHosts() { if (this.#allowHosts !== null) { return this.#allowHosts; } if (this.#server) { // If the server is bound to a hostname, not an IP address, return it as // allowed host. const hostUri = Services.io.newURI(`https://${this.#host}`); if (!this.#isIPAddress(hostUri)) { return [RemoteAgent.host]; } // Following Bug 1220810 localhost is guaranteed to resolve to a loopback // address (127.0.0.1 or ::1) unless network.proxy.allow_hijacking_localhost // is set to true, which should not be the case. const loopbackAddresses = ["127.0.0.1", "[::1]"]; // If the server is bound to an IP address and this IP address is a localhost // loopback address, return localhost as allowed host. if (loopbackAddresses.includes(this.#host)) { return ["localhost"]; } } // Otherwise return an empty array. return []; } get allowOrigins() { return this.#allowOrigins; } /** * A promise that resolves when the initial application window has been opened. * * @returns {Promise} * Promise that resolves when the initial application window is open. */ get browserStartupFinished() { return this.#browserStartupFinished.promise; } get cdp() { return this.#cdp; } get debuggerAddress() { if (!this.#server) { return ""; } return `${this.#host}:${this.#port}`; } get enabled() { return this.#enabled; } get host() { return this.#host; } get port() { return this.#port; } get running() { return !!this.#server && !this.#server.isStopped(); } get scheme() { return this.#server?.identity.primaryScheme; } get server() { return this.#server; } get webDriverBiDi() { return this.#webDriverBiDi; } /** * Check if the provided URI's host is an IP address. * * @param {nsIURI} uri * The URI to check. * @returns {boolean} */ #isIPAddress(uri) { try { // getBaseDomain throws an explicit error if the uri host is an IP address. Services.eTLD.getBaseDomain(uri); } catch (e) { return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS; } return false; } handle(cmdLine) { // remote-debugging-port has to be consumed in nsICommandLineHandler:handle // to avoid issues on macos. See Marionette.jsm::handle() for more details. // TODO: remove after Bug 1724251 is fixed. try { cmdLine.handleFlagWithParam("remote-debugging-port", false); } catch (e) { cmdLine.handleFlag("remote-debugging-port", false); } } async #listen(port) { if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { throw Components.Exception( "May only be instantiated in parent process", Cr.NS_ERROR_LAUNCHED_CHILD_PROCESS ); } if (this.running) { return; } // Try to resolve localhost to an IPv4 and / or IPv6 address so that the // server can be started on a given IP. Only fallback to use localhost if // the hostname cannot be resolved. // // Note: This doesn't force httpd.js to use the dual stack support. let isIPv4Host = false; try { const addresses = await this.#resolveHostname(DEFAULT_HOST); lazy.logger.trace( `Available local IP addresses: ${addresses.join(", ")}` ); // Prefer IPv4 over IPv6 addresses. const addressesIPv4 = addresses.filter(value => !value.includes(":")); isIPv4Host = !!addressesIPv4.length; if (isIPv4Host) { this.#host = addressesIPv4[0]; } else { this.#host = addresses.length ? addresses[0] : DEFAULT_HOST; } } catch (e) { this.#host = DEFAULT_HOST; lazy.logger.debug( `Failed to resolve hostname "localhost" to IP address: ${e.message}` ); } // nsIServerSocket uses -1 for atomic port allocation if (port === 0) { port = -1; } try { // Bug 1783938: httpd.js refuses connections when started on a IPv4 // address. As workaround start on localhost and add another identity // for that IP address. this.#server = new lazy.HttpServer(); const host = isIPv4Host ? DEFAULT_HOST : this.#host; this.server._start(port, host); this.#port = this.server._port; if (isIPv4Host) { this.server.identity.add("http", this.#host, this.#port); } Services.obs.notifyObservers(null, "remote-listening", true); await Promise.all([this.#webDriverBiDi?.start(), this.#cdp?.start()]); } catch (e) { await this.#stop(); lazy.logger.error(`Unable to start remote agent: ${e.message}`, e); } } /** * Resolves a hostname to one or more IP addresses. * * @param {string} hostname * * @returns {Array} */ #resolveHostname(hostname) { return new Promise((resolve, reject) => { let originalRequest; const onLookupCompleteListener = { onLookupComplete(request, record, status) { if (request === originalRequest) { if (!Components.isSuccessCode(status)) { reject({ message: ChromeUtils.getXPCOMErrorName(status) }); return; } record.QueryInterface(Ci.nsIDNSAddrRecord); const addresses = []; while (record.hasMore()) { let addr = record.getNextAddrAsString(); if (addr.includes(":") && !addr.startsWith("[")) { // Make sure that the IPv6 address is wrapped with brackets. addr = `[${addr}]`; } if (!addresses.includes(addr)) { // Sometimes there are duplicate records with the same IP. addresses.push(addr); } } resolve(addresses); } }, }; try { originalRequest = Services.dns.asyncResolve( hostname, Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, Ci.nsIDNSService.RESOLVE_BYPASS_CACHE, null, onLookupCompleteListener, null, //Services.tm.mainThread, {} /* defaultOriginAttributes */ ); } catch (e) { reject({ message: e.message }); } }); } async #stop() { if (!this.running) { return; } // Stop each protocol before stopping the HTTP server. await this.#cdp?.stop(); await this.#webDriverBiDi?.stop(); try { await this.#server.stop(); this.#server = null; Services.obs.notifyObservers(null, "remote-listening"); } catch (e) { // this function must never fail lazy.logger.error("Unable to stop listener", e); } } /** * Handle the --remote-debugging-port command line argument. * * @param {nsICommandLine} cmdLine * Instance of the command line interface. * * @returns {boolean} * Return `true` if the command line argument has been found. */ handleRemoteDebuggingPortFlag(cmdLine) { let enabled = false; try { // Catch cases when the argument, and a port have been specified. const port = cmdLine.handleFlagWithParam("remote-debugging-port", false); if (port !== null) { enabled = true; // In case of an invalid port keep the default port const parsed = Number(port); if (!isNaN(parsed)) { this.#port = parsed; } } } catch (e) { // If no port has been given check for the existence of the argument. enabled = cmdLine.handleFlag("remote-debugging-port", false); } return enabled; } handleAllowHostsFlag(cmdLine) { try { const hosts = cmdLine.handleFlagWithParam("remote-allow-hosts", false); return hosts.split(","); } catch (e) { return null; } } handleAllowOriginsFlag(cmdLine) { try { const origins = cmdLine.handleFlagWithParam( "remote-allow-origins", false ); return origins.split(","); } catch (e) { return null; } } async observe(subject, topic) { if (this.#enabled) { lazy.logger.trace(`Received observer notification ${topic}`); } switch (topic) { case "profile-after-change": Services.obs.addObserver(this, "command-line-startup"); break; case "command-line-startup": Services.obs.removeObserver(this, topic); this.#enabled = this.handleRemoteDebuggingPortFlag(subject); if (this.#enabled) { Services.obs.addObserver(this, "final-ui-startup"); this.#allowHosts = this.handleAllowHostsFlag(subject); this.#allowOrigins = this.handleAllowOriginsFlag(subject); Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); Services.obs.addObserver(this, "mail-idle-startup-tasks-finished"); Services.obs.addObserver(this, "quit-application"); // With Bug 1717899 we will extend the lifetime of the Remote Agent to // the whole Firefox session, which will be identical to Marionette. For // now prevent logging if the component is not enabled during startup. if ( (lazy.activeProtocols & WEBDRIVER_BIDI_ACTIVE) === WEBDRIVER_BIDI_ACTIVE ) { this.#webDriverBiDi = new lazy.WebDriverBiDi(this); if (this.#enabled) { lazy.logger.debug("WebDriver BiDi enabled"); } } if ((lazy.activeProtocols & CDP_ACTIVE) === CDP_ACTIVE) { this.#cdp = new lazy.CDP(this); if (this.#enabled) { lazy.logger.debug("CDP enabled"); } } } break; case "final-ui-startup": Services.obs.removeObserver(this, topic); try { await this.#listen(this.#port); } catch (e) { throw Error(`Unable to start remote agent: ${e}`); } break; // Used to wait until the initial application window has been opened. case "browser-idle-startup-tasks-finished": case "mail-idle-startup-tasks-finished": Services.obs.removeObserver( this, "browser-idle-startup-tasks-finished" ); Services.obs.removeObserver(this, "mail-idle-startup-tasks-finished"); this.#browserStartupFinished.resolve(); break; // Listen for application shutdown to also shutdown the Remote Agent // and a possible running instance of httpd.js. case "quit-application": Services.obs.removeObserver(this, topic); this.#stop(); break; } } receiveMessage({ name }) { switch (name) { case "RemoteAgent:IsRunning": return this.running; default: lazy.logger.warn("Unknown IPC message to parent process: " + name); return null; } } // XPCOM get classID() { return this.#classID; } get helpInfo() { return ` --remote-debugging-port [] Start the Firefox Remote Agent, which is a low-level remote debugging interface used for WebDriver BiDi and CDP. Defaults to port 9222. --remote-allow-hosts Values of the Host header to allow for incoming requests. Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html --remote-allow-origins Values of the Origin header to allow for incoming requests. Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html\n`; } get QueryInterface() { return ChromeUtils.generateQI([ "nsICommandLineHandler", "nsIObserver", "nsIRemoteAgent", ]); } } class RemoteAgentContentProcess { #classID; constructor() { this.#classID = Components.ID("{8f685a9d-8181-46d6-a71d-869289099c6d}"); } get running() { let reply = Services.cpmm.sendSyncMessage("RemoteAgent:IsRunning"); if (!reply.length) { lazy.logger.warn("No reply from parent process"); return false; } return reply[0]; } get QueryInterface() { return ChromeUtils.generateQI(["nsIRemoteAgent"]); } } export var RemoteAgent; if (isRemote) { RemoteAgent = new RemoteAgentContentProcess(); } else { RemoteAgent = new RemoteAgentParentProcess(); } // This is used by the XPCOM codepath which expects a constructor export var RemoteAgentFactory = function () { return RemoteAgent; };