gecko-dev/remote/components/RemoteAgent.sys.mjs

516 lines
14 KiB
JavaScript

/* 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",
RecommendedPreferences:
"chrome://remote/content/shared/RecommendedPreferences.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.sys.mjs::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<string>}
*/
#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");
// Apply the common set of preferences for all supported protocols
lazy.RecommendedPreferences.applyPreferences();
// 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 [<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 <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 <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 {
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;
};