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