socket.js (19607B)
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/. */ 4 5 "use strict"; 6 7 // Ensure PSM is initialized to support TLS sockets 8 Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports); 9 10 var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); 11 var { dumpn } = DevToolsUtils; 12 loader.lazyRequireGetter( 13 this, 14 "WebSocketServer", 15 "resource://devtools/server/socket/websocket-server.js" 16 ); 17 loader.lazyRequireGetter( 18 this, 19 "DebuggerTransport", 20 "resource://devtools/shared/transport/transport.js", 21 true 22 ); 23 loader.lazyRequireGetter( 24 this, 25 "WebSocketDebuggerTransport", 26 "resource://devtools/shared/transport/websocket-transport.js" 27 ); 28 loader.lazyRequireGetter( 29 this, 30 "discovery", 31 "resource://devtools/shared/discovery/discovery.js" 32 ); 33 loader.lazyRequireGetter( 34 this, 35 "Authenticators", 36 "resource://devtools/shared/security/auth.js", 37 true 38 ); 39 loader.lazyRequireGetter( 40 this, 41 "AuthenticationResult", 42 "resource://devtools/shared/security/auth.js", 43 true 44 ); 45 const lazy = {}; 46 47 DevToolsUtils.defineLazyGetter( 48 lazy, 49 "DevToolsSocketStatus", 50 () => 51 ChromeUtils.importESModule( 52 "resource://devtools/shared/security/DevToolsSocketStatus.sys.mjs", 53 // DevToolsSocketStatus is also accessed by non-devtools modules and 54 // should be loaded in the regular / shared global. 55 { global: "shared" } 56 ).DevToolsSocketStatus 57 ); 58 59 loader.lazyRequireGetter( 60 this, 61 "EventEmitter", 62 "resource://devtools/shared/event-emitter.js" 63 ); 64 65 DevToolsUtils.defineLazyGetter(this, "nsFile", () => { 66 return Components.Constructor( 67 "@mozilla.org/file/local;1", 68 "nsIFile", 69 "initWithPath" 70 ); 71 }); 72 73 DevToolsUtils.defineLazyGetter(this, "socketTransportService", () => { 74 return Cc["@mozilla.org/network/socket-transport-service;1"].getService( 75 Ci.nsISocketTransportService 76 ); 77 }); 78 79 class DebuggerSocket { 80 /** 81 * Connects to a devtools server socket. 82 * 83 * @param {object} settings 84 * @param {string} settings.host 85 * The host name or IP address of the devtools server. 86 * @param {number} settings.port 87 * The port number of the devtools server. 88 * @param {boolean} [settings.webSocket] 89 * Whether to use WebSocket protocol to connect. Defaults to false. 90 * @param {Authenticator} [settings.authenticator] 91 * |Authenticator| instance matching the mode in use by the server. 92 * Defaults to a PROMPT instance if not supplied. 93 * @return {Promise} 94 * Resolved to a DebuggerTransport instance. 95 */ 96 static async connect(settings) { 97 // Default to PROMPT |Authenticator| instance if not supplied 98 if (!settings.authenticator) { 99 settings.authenticator = new (Authenticators.get().Client)(); 100 } 101 _validateSettings(settings); 102 // eslint-disable-next-line no-shadow 103 const { host, port, authenticator } = settings; 104 const transport = await _getTransport(settings); 105 await authenticator.authenticate({ 106 host, 107 port, 108 transport, 109 }); 110 transport.connectionSettings = settings; 111 return transport; 112 } 113 } 114 /** 115 * Validate that the connection settings have been set to a supported configuration. 116 */ 117 function _validateSettings(settings) { 118 const { authenticator } = settings; 119 120 authenticator.validateSettings(settings); 121 } 122 123 /** 124 * Try very hard to create a DevTools transport, potentially making several 125 * connect attempts in the process. 126 * 127 * @param host string 128 * The host name or IP address of the devtools server. 129 * @param port number 130 * The port number of the devtools server. 131 * @param webSocket boolean (optional) 132 * Whether to use WebSocket protocol to connect to the server. Defaults to false. 133 * @param authenticator Authenticator 134 * |Authenticator| instance matching the mode in use by the server. 135 * Defaults to a PROMPT instance if not supplied. 136 * @return transport DebuggerTransport 137 * A possible DevTools transport (if connection succeeded and streams 138 * are actually alive and working) 139 */ 140 var _getTransport = async function (settings) { 141 const { host, port, webSocket } = settings; 142 143 if (webSocket) { 144 // Establish a connection and wait until the WebSocket is ready to send and receive 145 const socket = await new Promise((resolve, reject) => { 146 const s = new WebSocket(`ws://${host}:${port}`); 147 s.onopen = () => resolve(s); 148 s.onerror = err => reject(err); 149 }); 150 151 return new WebSocketDebuggerTransport(socket); 152 } 153 154 const attempt = await _attemptTransport(settings); 155 if (attempt.transport) { 156 // Success 157 return attempt.transport; 158 } 159 160 throw new Error("Connection failed"); 161 }; 162 163 /** 164 * Make a single attempt to connect and create a DevTools transport. 165 * 166 * @param host string 167 * The host name or IP address of the devtools server. 168 * @param port number 169 * The port number of the devtools server. 170 * @param authenticator Authenticator 171 * |Authenticator| instance matching the mode in use by the server. 172 * Defaults to a PROMPT instance if not supplied. 173 * @return transport DebuggerTransport 174 * A possible DevTools transport (if connection succeeded and streams 175 * are actually alive and working) 176 * @return s nsISocketTransport 177 * Underlying socket transport, in case more details are needed. 178 */ 179 var _attemptTransport = async function (settings) { 180 const { authenticator } = settings; 181 // _attemptConnect only opens the streams. Any failures at that stage 182 // aborts the connection process immedidately. 183 const { s, input, output } = await _attemptConnect(settings); 184 185 // Check if the input stream is alive. 186 let alive; 187 try { 188 const results = await _isInputAlive(input); 189 alive = results.alive; 190 } catch (e) { 191 // For other unexpected errors, like NS_ERROR_CONNECTION_REFUSED, we reach 192 // this block. 193 input.close(); 194 output.close(); 195 throw e; 196 } 197 198 // The |Authenticator| examines the connection as well and may determine it 199 // should be dropped. 200 alive = 201 alive && 202 authenticator.validateConnection({ 203 host: settings.host, 204 port: settings.port, 205 socket: s, 206 }); 207 208 let transport; 209 if (alive) { 210 transport = new DebuggerTransport(input, output); 211 } else { 212 // Something went wrong, close the streams. 213 input.close(); 214 output.close(); 215 } 216 217 return { transport, s }; 218 }; 219 220 /** 221 * Try to connect to a remote server socket. 222 * 223 * If successsful, the socket transport and its opened streams are returned. 224 * Typically, this will only fail if the host / port is unreachable. Other 225 * problems, such as security errors, will allow this stage to succeed, but then 226 * fail later when the streams are actually used. 227 * 228 * @return s nsISocketTransport 229 * Underlying socket transport, in case more details are needed. 230 * @return input nsIAsyncInputStream 231 * The socket's input stream. 232 * @return output nsIAsyncOutputStream 233 * The socket's output stream. 234 */ 235 var _attemptConnect = async function ({ host, port }) { 236 const s = socketTransportService.createTransport([], host, port, null, null); 237 238 // Force disabling IPV6 if we aren't explicitely connecting to an IPv6 address 239 // It fails intermitently on MacOS when opening the Browser Toolbox (bug 1615412) 240 if (!host.includes(":")) { 241 s.connectionFlags |= Ci.nsISocketTransport.DISABLE_IPV6; 242 } 243 244 // By default the CONNECT socket timeout is very long, 65535 seconds, 245 // so that if we race to be in CONNECT state while the server socket is still 246 // initializing, the connection is stuck in connecting state for 18.20 hours! 247 s.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, 2); 248 249 let input; 250 let output; 251 return new Promise((resolve, reject) => { 252 s.setEventSink( 253 { 254 onTransportStatus(transport, status) { 255 if (status != Ci.nsISocketTransport.STATUS_CONNECTING_TO) { 256 return; 257 } 258 try { 259 input = s.openInputStream(0, 0, 0); 260 } catch (e) { 261 reject(e); 262 } 263 resolve({ s, input, output }); 264 }, 265 }, 266 Services.tm.currentThread 267 ); 268 269 // openOutputStream may throw NS_ERROR_NOT_INITIALIZED if we hit some race 270 // where the nsISocketTransport gets shutdown in between its instantiation and 271 // the call to this method. 272 try { 273 output = s.openOutputStream(0, 0, 0); 274 } catch (e) { 275 reject(e); 276 } 277 }).catch(e => { 278 if (input) { 279 input.close(); 280 } 281 if (output) { 282 output.close(); 283 } 284 DevToolsUtils.reportException("_attemptConnect", e); 285 }); 286 }; 287 288 /** 289 * Check if the input stream is alive. 290 */ 291 function _isInputAlive(input) { 292 return new Promise((resolve, reject) => { 293 input.asyncWait( 294 { 295 onInputStreamReady(stream) { 296 try { 297 stream.available(); 298 resolve({ alive: true }); 299 } catch (e) { 300 reject(e); 301 } 302 }, 303 }, 304 0, 305 0, 306 Services.tm.currentThread 307 ); 308 }); 309 } 310 311 /** 312 * Creates a new socket listener for remote connections to the DevToolsServer. 313 * This helps contain and organize the parts of the server that may differ or 314 * are particular to one given listener mechanism vs. another. 315 * This can be closed at any later time by calling |close|. 316 * If remote connections are disabled, an error is thrown. 317 */ 318 class SocketListener extends EventEmitter { 319 /** 320 * @param {DevToolsServer} devToolsServer 321 * @param {object} socketOptions 322 * options of socket as follows 323 * { 324 * authenticator: 325 * Controls the |Authenticator| used, which hooks various socket steps to 326 * implement an authentication policy. It is expected that different use 327 * cases may override pieces of the |Authenticator|. See auth.js. 328 * We set the default |Authenticator|, which is |Prompt|. 329 * discoverable: 330 * Controls whether this listener is announced via the service discovery 331 * mechanism. Defaults is false. 332 * fromBrowserToolbox: 333 * Should only be passed when opening a socket for a Browser Toolbox 334 * session. DevToolsSocketStatus will track the socket separately to 335 * avoid triggering the visual cue in the URL bar. 336 * portOrPath: 337 * The port or path to listen on. 338 * If given an integer, the port to listen on. Use -1 to choose any available 339 * port. Otherwise, the path to the unix socket domain file to listen on. 340 * Defaults is null. 341 * webSocket: 342 * Whether to use WebSocket protocol. Defaults is false. 343 * } 344 */ 345 constructor(devToolsServer, socketOptions) { 346 super(); 347 348 this._devToolsServer = devToolsServer; 349 350 // Set socket options with default value 351 this._socketOptions = { 352 authenticator: 353 socketOptions.authenticator || new (Authenticators.get().Server)(), 354 discoverable: !!socketOptions.discoverable, 355 fromBrowserToolbox: !!socketOptions.fromBrowserToolbox, 356 portOrPath: socketOptions.portOrPath || null, 357 webSocket: !!socketOptions.webSocket, 358 }; 359 } 360 361 get authenticator() { 362 return this._socketOptions.authenticator; 363 } 364 365 get discoverable() { 366 return this._socketOptions.discoverable; 367 } 368 369 get fromBrowserToolbox() { 370 return this._socketOptions.fromBrowserToolbox; 371 } 372 373 get portOrPath() { 374 return this._socketOptions.portOrPath; 375 } 376 377 get webSocket() { 378 return this._socketOptions.webSocket; 379 } 380 381 /** 382 * Validate that all options have been set to a supported configuration. 383 */ 384 _validateOptions() { 385 if (this.portOrPath === null) { 386 throw new Error("Must set a port / path to listen on."); 387 } 388 if (this.discoverable && !Number(this.portOrPath)) { 389 throw new Error("Discovery only supported for TCP sockets."); 390 } 391 } 392 393 /** 394 * Listens on the given port or socket file for remote debugger connections. 395 */ 396 open() { 397 this._validateOptions(); 398 this._devToolsServer.addSocketListener(this); 399 400 let flags = Ci.nsIServerSocket.KeepWhenOffline; 401 // A preference setting can force binding on the loopback interface. 402 if (Services.prefs.getBoolPref("devtools.debugger.force-local")) { 403 flags |= Ci.nsIServerSocket.LoopbackOnly; 404 } 405 406 const self = this; 407 return (async function () { 408 const backlog = 4; 409 self._socket = self._createSocketInstance(); 410 if (self.isPortBased) { 411 const port = Number(self.portOrPath); 412 self._socket.initSpecialConnection(port, flags, backlog); 413 } else if (self.portOrPath.startsWith("/")) { 414 const file = nsFile(self.portOrPath); 415 if (file.exists()) { 416 file.remove(false); 417 } 418 self._socket.initWithFilename(file, parseInt("666", 8), backlog); 419 } else { 420 // Path isn't absolute path, so we use abstract socket address 421 self._socket.initWithAbstractAddress(self.portOrPath, backlog); 422 } 423 self._socket.asyncListen(self); 424 dumpn("Socket listening on: " + (self.port || self.portOrPath)); 425 })() 426 .then(() => { 427 lazy.DevToolsSocketStatus.notifySocketOpened({ 428 fromBrowserToolbox: self.fromBrowserToolbox, 429 }); 430 this._advertise(); 431 }) 432 .catch(e => { 433 dumpn( 434 "Could not start debugging listener on '" + 435 this.portOrPath + 436 "': " + 437 e 438 ); 439 this.close(); 440 }); 441 } 442 443 _advertise() { 444 if (!this.discoverable || !this.port) { 445 return; 446 } 447 448 const advertisement = { 449 port: this.port, 450 }; 451 452 this.authenticator.augmentAdvertisement(this, advertisement); 453 454 discovery.addService("devtools", advertisement); 455 } 456 457 _createSocketInstance() { 458 return Cc["@mozilla.org/network/server-socket;1"].createInstance( 459 Ci.nsIServerSocket 460 ); 461 } 462 463 /** 464 * Closes the SocketListener. Notifies the server to remove the listener from 465 * the set of active SocketListeners. 466 */ 467 close() { 468 if (this.discoverable && this.port) { 469 discovery.removeService("devtools"); 470 } 471 if (this._socket) { 472 this._socket.close(); 473 this._socket = null; 474 475 lazy.DevToolsSocketStatus.notifySocketClosed({ 476 fromBrowserToolbox: this.fromBrowserToolbox, 477 }); 478 } 479 this._devToolsServer.removeSocketListener(this); 480 } 481 482 get host() { 483 if (!this._socket) { 484 return null; 485 } 486 if (Services.prefs.getBoolPref("devtools.debugger.force-local")) { 487 return "127.0.0.1"; 488 } 489 return "0.0.0.0"; 490 } 491 492 /** 493 * Gets whether this listener uses a port number vs. a path. 494 */ 495 get isPortBased() { 496 return !!Number(this.portOrPath); 497 } 498 499 /** 500 * Gets the port that a TCP socket listener is listening on, or null if this 501 * is not a TCP socket (so there is no port). 502 */ 503 get port() { 504 if (!this.isPortBased || !this._socket) { 505 return null; 506 } 507 return this._socket.port; 508 } 509 510 onAllowedConnection(transport) { 511 dumpn("onAllowedConnection, transport: " + transport); 512 this.emit("accepted", transport, this); 513 } 514 515 // nsIServerSocketListener implementation 516 517 onSocketAccepted = DevToolsUtils.makeInfallible(function ( 518 socket, 519 socketTransport 520 ) { 521 const connection = new ServerSocketConnection(this, socketTransport); 522 connection.once("allowed", this.onAllowedConnection.bind(this)); 523 }, "SocketListener.onSocketAccepted"); 524 525 onStopListening(socket, status) { 526 dumpn("onStopListening, status: " + status); 527 } 528 } 529 530 /** 531 * A |ServerSocketConnection| is created by a |SocketListener| for each accepted 532 * incoming socket. 533 */ 534 class ServerSocketConnection extends EventEmitter { 535 constructor(listener, socketTransport) { 536 super(); 537 538 this._listener = listener; 539 this._socketTransport = socketTransport; 540 this._handle(); 541 } 542 get authentication() { 543 return this._listener.authenticator.mode; 544 } 545 546 get host() { 547 return this._socketTransport.host; 548 } 549 550 get port() { 551 return this._socketTransport.port; 552 } 553 554 get address() { 555 return this.host + ":" + this.port; 556 } 557 558 get client() { 559 const client = { 560 host: this.host, 561 port: this.port, 562 }; 563 return client; 564 } 565 566 get server() { 567 const server = { 568 host: this._listener.host, 569 port: this._listener.port, 570 }; 571 return server; 572 } 573 574 /** 575 * This is the main authentication workflow. If any pieces reject a promise, 576 * the connection is denied. If the entire process resolves successfully, 577 * the connection is finally handed off to the |DevToolsServer|. 578 */ 579 async _handle() { 580 dumpn("Debugging connection starting authentication on " + this.address); 581 try { 582 await this._createTransport(); 583 await this._authenticate(); 584 this.allow(); 585 } catch (e) { 586 this.deny(e); 587 } 588 } 589 590 /** 591 * We need to open the streams early on, as that is required in the case of 592 * TLS sockets to keep the handshake moving. 593 */ 594 async _createTransport() { 595 const input = this._socketTransport.openInputStream(0, 0, 0); 596 const output = this._socketTransport.openOutputStream(0, 0, 0); 597 598 if (this._listener.webSocket) { 599 const socket = await WebSocketServer.accept( 600 this._socketTransport, 601 input, 602 output 603 ); 604 this._transport = new WebSocketDebuggerTransport(socket); 605 } else { 606 this._transport = new DebuggerTransport(input, output); 607 } 608 609 // Start up the transport to observe the streams in case they are closed 610 // early. This allows us to clean up our state as well. 611 this._transport.hooks = { 612 onTransportClosed: reason => { 613 this.deny(reason); 614 }, 615 }; 616 this._transport.ready(); 617 } 618 619 async _authenticate() { 620 const result = await this._listener.authenticator.authenticate({ 621 client: this.client, 622 server: this.server, 623 transport: this._transport, 624 }); 625 626 // If result is fine, we can stop here 627 if ( 628 result === AuthenticationResult.ALLOW || 629 result === AuthenticationResult.ALLOW_PERSIST 630 ) { 631 return; 632 } 633 634 if (result === AuthenticationResult.DISABLE_ALL) { 635 this._listener._devToolsServer.closeAllSocketListeners(); 636 Services.prefs.setBoolPref("devtools.debugger.remote-enabled", false); 637 } 638 639 // If we got an error (DISABLE_ALL, DENY, …), let's throw a NS_ERROR_CONNECTION_REFUSED 640 // exception 641 throw Components.Exception("", Cr.NS_ERROR_CONNECTION_REFUSED); 642 } 643 644 deny(result) { 645 if (this._destroyed) { 646 return; 647 } 648 let errorName = result; 649 for (const name in Cr) { 650 if (Cr[name] === result) { 651 errorName = name; 652 break; 653 } 654 } 655 dumpn( 656 "Debugging connection denied on " + this.address + " (" + errorName + ")" 657 ); 658 if (this._transport) { 659 this._transport.hooks = null; 660 this._transport.close(result); 661 } 662 this._socketTransport.close(result); 663 this.destroy(); 664 } 665 666 allow() { 667 if (this._destroyed) { 668 return; 669 } 670 dumpn("Debugging connection allowed on " + this.address); 671 this.emit("allowed", this._transport); 672 this.destroy(); 673 } 674 675 destroy() { 676 this._destroyed = true; 677 this._listener = null; 678 this._socketTransport = null; 679 this._transport = null; 680 } 681 } 682 683 exports.DebuggerSocket = DebuggerSocket; 684 exports.SocketListener = SocketListener;