Bug 1793677 [wpt PR 36271] - Run sysdiagnose at the end of Azure Pipelines jobs on...
[gecko.git] / remote / components / RemoteAgent.sys.mjs
blobbe37cdc463b244b32355b56d733738a3a3de62fb
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   CDP: "chrome://remote/content/cdp/CDP.sys.mjs",
11   Deferred: "chrome://remote/content/shared/Sync.sys.mjs",
12   Log: "chrome://remote/content/shared/Log.sys.mjs",
13   WebDriverBiDi: "chrome://remote/content/webdriver-bidi/WebDriverBiDi.sys.mjs",
14 });
16 XPCOMUtils.defineLazyModuleGetters(lazy, {
17   HttpServer: "chrome://remote/content/server/HTTPD.jsm",
18 });
20 XPCOMUtils.defineLazyServiceGetter(
21   lazy,
22   "DNSService",
23   "@mozilla.org/network/dns-service;1",
24   "nsIDNSService"
27 XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
29 XPCOMUtils.defineLazyGetter(lazy, "activeProtocols", () => {
30   const protocols = Services.prefs.getIntPref("remote.active-protocols");
31   if (protocols < 1 || protocols > 3) {
32     throw Error(`Invalid remote protocol identifier: ${protocols}`);
33   }
35   return protocols;
36 });
38 const WEBDRIVER_BIDI_ACTIVE = 0x1;
39 const CDP_ACTIVE = 0x2;
41 const DEFAULT_HOST = "localhost";
42 const DEFAULT_PORT = 9222;
44 const isRemote =
45   Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT;
47 class RemoteAgentParentProcess {
48   #allowHosts;
49   #allowOrigins;
50   #browserStartupFinished;
51   #classID;
52   #enabled;
53   #host;
54   #port;
55   #server;
57   #cdp;
58   #webDriverBiDi;
60   constructor() {
61     this.#allowHosts = null;
62     this.#allowOrigins = null;
63     this.#browserStartupFinished = lazy.Deferred();
64     this.#classID = Components.ID("{8f685a9d-8181-46d6-a71d-869289099c6d}");
65     this.#enabled = false;
67     // Configuration for httpd.js
68     this.#host = DEFAULT_HOST;
69     this.#port = DEFAULT_PORT;
70     this.#server = null;
72     // Supported protocols
73     this.#cdp = null;
74     this.#webDriverBiDi = null;
76     Services.ppmm.addMessageListener("RemoteAgent:IsRunning", this);
77   }
79   get allowHosts() {
80     if (this.#allowHosts !== null) {
81       return this.#allowHosts;
82     }
84     if (this.#server) {
85       // If the server is bound to a hostname, not an IP address, return it as
86       // allowed host.
87       const hostUri = Services.io.newURI(`https://${this.#host}`);
88       if (!this.#isIPAddress(hostUri)) {
89         return [RemoteAgent.host];
90       }
92       // Following Bug 1220810 localhost is guaranteed to resolve to a loopback
93       // address (127.0.0.1 or ::1) unless network.proxy.allow_hijacking_localhost
94       // is set to true, which should not be the case.
95       const loopbackAddresses = ["127.0.0.1", "[::1]"];
97       // If the server is bound to an IP address and this IP address is a localhost
98       // loopback address, return localhost as allowed host.
99       if (loopbackAddresses.includes(this.#host)) {
100         return ["localhost"];
101       }
102     }
104     // Otherwise return an empty array.
105     return [];
106   }
108   get allowOrigins() {
109     return this.#allowOrigins;
110   }
112   /**
113    * A promise that resolves when the initial application window has been opened.
114    *
115    * @returns {Promise}
116    *     Promise that resolves when the initial application window is open.
117    */
118   get browserStartupFinished() {
119     return this.#browserStartupFinished.promise;
120   }
122   get cdp() {
123     return this.#cdp;
124   }
126   get debuggerAddress() {
127     if (!this.#server) {
128       return "";
129     }
131     return `${this.#host}:${this.#port}`;
132   }
134   get enabled() {
135     return this.#enabled;
136   }
138   get host() {
139     return this.#host;
140   }
142   get port() {
143     return this.#port;
144   }
146   get running() {
147     return !!this.#server && !this.#server.isStopped();
148   }
150   get scheme() {
151     return this.#server?.identity.primaryScheme;
152   }
154   get server() {
155     return this.#server;
156   }
158   get webDriverBiDi() {
159     return this.#webDriverBiDi;
160   }
162   /**
163    * Check if the provided URI's host is an IP address.
164    *
165    * @param {nsIURI} uri
166    *     The URI to check.
167    * @return {boolean}
168    */
169   #isIPAddress(uri) {
170     try {
171       // getBaseDomain throws an explicit error if the uri host is an IP address.
172       Services.eTLD.getBaseDomain(uri);
173     } catch (e) {
174       return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS;
175     }
176     return false;
177   }
179   handle(cmdLine) {
180     // remote-debugging-port has to be consumed in nsICommandLineHandler:handle
181     // to avoid issues on macos. See Marionette.jsm::handle() for more details.
182     // TODO: remove after Bug 1724251 is fixed.
183     try {
184       cmdLine.handleFlagWithParam("remote-debugging-port", false);
185     } catch (e) {
186       cmdLine.handleFlag("remote-debugging-port", false);
187     }
188   }
190   async #listen(port) {
191     if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
192       throw Components.Exception(
193         "May only be instantiated in parent process",
194         Cr.NS_ERROR_LAUNCHED_CHILD_PROCESS
195       );
196     }
198     if (this.running) {
199       return;
200     }
202     // Try to resolve localhost to an IPv4  and / or IPv6 address so that the
203     // server can be started on a given IP. Only fallback to use localhost if
204     // the hostname cannot be resolved.
205     //
206     // Note: This doesn't force httpd.js to use the dual stack support.
207     let isIPv4Host = false;
208     try {
209       const addresses = await this.#resolveHostname(DEFAULT_HOST);
210       lazy.logger.trace(
211         `Available local IP addresses: ${addresses.join(", ")}`
212       );
214       // Prefer IPv4 over IPv6 addresses.
215       const addressesIPv4 = addresses.filter(value => !value.includes(":"));
216       isIPv4Host = !!addressesIPv4.length;
217       if (isIPv4Host) {
218         this.#host = addressesIPv4[0];
219       } else {
220         this.#host = addresses.length ? addresses[0] : DEFAULT_HOST;
221       }
222     } catch (e) {
223       this.#host = DEFAULT_HOST;
225       lazy.logger.debug(
226         `Failed to resolve hostname "localhost" to IP address: ${e.message}`
227       );
228     }
230     // nsIServerSocket uses -1 for atomic port allocation
231     if (port === 0) {
232       port = -1;
233     }
235     try {
236       // Bug 1783938: httpd.js refuses connections when started on a IPv4
237       // address. As workaround start on localhost and add another identity
238       // for that IP address.
239       this.#server = new lazy.HttpServer();
240       const host = isIPv4Host ? DEFAULT_HOST : this.#host;
241       this.server._start(port, host);
242       this.#port = this.server._port;
244       if (isIPv4Host) {
245         this.server.identity.add("http", this.#host, this.#port);
246       }
248       Services.obs.notifyObservers(null, "remote-listening", true);
250       await Promise.all([this.#webDriverBiDi?.start(), this.#cdp?.start()]);
251     } catch (e) {
252       await this.#stop();
253       lazy.logger.error(`Unable to start remote agent: ${e.message}`, e);
254     }
255   }
257   /**
258    * Resolves a hostname to one or more IP addresses.
259    *
260    * @param {string} hostname
261    *
262    * @returns {Array<string>}
263    */
264   #resolveHostname(hostname) {
265     return new Promise((resolve, reject) => {
266       let originalRequest;
268       const onLookupCompleteListener = {
269         onLookupComplete(request, record, status) {
270           if (request === originalRequest) {
271             if (!Components.isSuccessCode(status)) {
272               reject({ message: ChromeUtils.getXPCOMErrorName(status) });
273               return;
274             }
276             record.QueryInterface(Ci.nsIDNSAddrRecord);
278             const addresses = [];
279             while (record.hasMore()) {
280               let addr = record.getNextAddrAsString();
281               if (addr.includes(":") && !addr.startsWith("[")) {
282                 // Make sure that the IPv6 address is wrapped with brackets.
283                 addr = `[${addr}]`;
284               }
285               if (!addresses.includes(addr)) {
286                 // Sometimes there are duplicate records with the same IP.
287                 addresses.push(addr);
288               }
289             }
291             resolve(addresses);
292           }
293         },
294       };
296       try {
297         originalRequest = lazy.DNSService.asyncResolve(
298           hostname,
299           Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
300           Ci.nsIDNSService.RESOLVE_BYPASS_CACHE,
301           null,
302           onLookupCompleteListener,
303           null, //Services.tm.mainThread,
304           {} /* defaultOriginAttributes */
305         );
306       } catch (e) {
307         reject({ message: e.message });
308       }
309     });
310   }
312   async #stop() {
313     if (!this.running) {
314       return;
315     }
317     try {
318       // Stop the CDP support before stopping the server.
319       // Otherwise the HTTP server will fail to stop.
320       await this.#cdp?.stop();
321       await this.#webDriverBiDi?.stop();
323       await this.#server.stop();
324       this.#server = null;
325       Services.obs.notifyObservers(null, "remote-listening");
326     } catch (e) {
327       // this function must never fail
328       lazy.logger.error("unable to stop listener", e);
329     }
330   }
332   /**
333    * Handle the --remote-debugging-port command line argument.
334    *
335    * @param {nsICommandLine} cmdLine
336    *     Instance of the command line interface.
337    *
338    * @return {boolean}
339    *     Return `true` if the command line argument has been found.
340    */
341   handleRemoteDebuggingPortFlag(cmdLine) {
342     let enabled = false;
344     try {
345       // Catch cases when the argument, and a port have been specified.
346       const port = cmdLine.handleFlagWithParam("remote-debugging-port", false);
347       if (port !== null) {
348         enabled = true;
350         // In case of an invalid port keep the default port
351         const parsed = Number(port);
352         if (!isNaN(parsed)) {
353           this.#port = parsed;
354         }
355       }
356     } catch (e) {
357       // If no port has been given check for the existence of the argument.
358       enabled = cmdLine.handleFlag("remote-debugging-port", false);
359     }
361     return enabled;
362   }
364   handleAllowHostsFlag(cmdLine) {
365     try {
366       const hosts = cmdLine.handleFlagWithParam("remote-allow-hosts", false);
367       return hosts.split(",");
368     } catch (e) {
369       return null;
370     }
371   }
373   handleAllowOriginsFlag(cmdLine) {
374     try {
375       const origins = cmdLine.handleFlagWithParam(
376         "remote-allow-origins",
377         false
378       );
379       return origins.split(",");
380     } catch (e) {
381       return null;
382     }
383   }
385   async observe(subject, topic) {
386     if (this.#enabled) {
387       lazy.logger.trace(`Received observer notification ${topic}`);
388     }
390     switch (topic) {
391       case "profile-after-change":
392         Services.obs.addObserver(this, "command-line-startup");
393         break;
395       case "command-line-startup":
396         Services.obs.removeObserver(this, topic);
398         this.#enabled = this.handleRemoteDebuggingPortFlag(subject);
400         if (this.#enabled) {
401           Services.obs.addObserver(this, "final-ui-startup");
403           this.#allowHosts = this.handleAllowHostsFlag(subject);
404           this.#allowOrigins = this.handleAllowOriginsFlag(subject);
406           Services.obs.addObserver(this, "browser-idle-startup-tasks-finished");
407           Services.obs.addObserver(this, "mail-idle-startup-tasks-finished");
408           Services.obs.addObserver(this, "quit-application");
410           // With Bug 1717899 we will extend the lifetime of the Remote Agent to
411           // the whole Firefox session, which will be identical to Marionette. For
412           // now prevent logging if the component is not enabled during startup.
413           if (
414             (lazy.activeProtocols & WEBDRIVER_BIDI_ACTIVE) ===
415             WEBDRIVER_BIDI_ACTIVE
416           ) {
417             this.#webDriverBiDi = new lazy.WebDriverBiDi(this);
418             if (this.#enabled) {
419               lazy.logger.debug("WebDriver BiDi enabled");
420             }
421           }
423           if ((lazy.activeProtocols & CDP_ACTIVE) === CDP_ACTIVE) {
424             this.#cdp = new lazy.CDP(this);
425             if (this.#enabled) {
426               lazy.logger.debug("CDP enabled");
427             }
428           }
429         }
430         break;
432       case "final-ui-startup":
433         Services.obs.removeObserver(this, topic);
435         try {
436           await this.#listen(this.#port);
437         } catch (e) {
438           throw Error(`Unable to start remote agent: ${e}`);
439         }
441         break;
443       // Used to wait until the initial application window has been opened.
444       case "browser-idle-startup-tasks-finished":
445       case "mail-idle-startup-tasks-finished":
446         Services.obs.removeObserver(
447           this,
448           "browser-idle-startup-tasks-finished"
449         );
450         Services.obs.removeObserver(this, "mail-idle-startup-tasks-finished");
451         this.#browserStartupFinished.resolve();
452         break;
454       // Listen for application shutdown to also shutdown the Remote Agent
455       // and a possible running instance of httpd.js.
456       case "quit-application":
457         Services.obs.removeObserver(this, topic);
458         this.#stop();
459         break;
460     }
461   }
463   receiveMessage({ name }) {
464     switch (name) {
465       case "RemoteAgent:IsRunning":
466         return this.running;
468       default:
469         lazy.logger.warn("Unknown IPC message to parent process: " + name);
470         return null;
471     }
472   }
474   // XPCOM
476   get classID() {
477     return this.#classID;
478   }
480   get helpInfo() {
481     return `  --remote-debugging-port [<port>] Start the Firefox Remote Agent,
482                      which is a low-level remote debugging interface used for WebDriver
483                      BiDi and CDP. Defaults to port 9222.
484   --remote-allow-hosts <hosts> Values of the Host header to allow for incoming requests.
485                      Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html
486   --remote-allow-origins <origins> Values of the Origin header to allow for incoming requests.
487                      Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html\n`;
488   }
490   get QueryInterface() {
491     return ChromeUtils.generateQI([
492       "nsICommandLineHandler",
493       "nsIObserver",
494       "nsIRemoteAgent",
495     ]);
496   }
499 class RemoteAgentContentProcess {
500   #classID;
502   constructor() {
503     this.#classID = Components.ID("{8f685a9d-8181-46d6-a71d-869289099c6d}");
504   }
506   get running() {
507     let reply = Services.cpmm.sendSyncMessage("RemoteAgent:IsRunning");
508     if (!reply.length) {
509       lazy.logger.warn("No reply from parent process");
510       return false;
511     }
512     return reply[0];
513   }
515   get QueryInterface() {
516     return ChromeUtils.generateQI(["nsIRemoteAgent"]);
517   }
520 export var RemoteAgent;
521 if (isRemote) {
522   RemoteAgent = new RemoteAgentContentProcess();
523 } else {
524   RemoteAgent = new RemoteAgentParentProcess();
527 // This is used by the XPCOM codepath which expects a constructor
528 export var RemoteAgentFactory = function() {
529   return RemoteAgent;