682 lines
19 KiB
JavaScript
682 lines
19 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/. */
|
|
|
|
"use strict";
|
|
|
|
// Ensure PSM is initialized to support TLS sockets
|
|
Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
|
|
|
|
var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
|
|
var { dumpn } = DevToolsUtils;
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"WebSocketServer",
|
|
"resource://devtools/server/socket/websocket-server.js"
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"DebuggerTransport",
|
|
"resource://devtools/shared/transport/transport.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"WebSocketDebuggerTransport",
|
|
"resource://devtools/shared/transport/websocket-transport.js"
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"discovery",
|
|
"resource://devtools/shared/discovery/discovery.js"
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"Authenticators",
|
|
"resource://devtools/shared/security/auth.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"AuthenticationResult",
|
|
"resource://devtools/shared/security/auth.js",
|
|
true
|
|
);
|
|
const lazy = {};
|
|
|
|
DevToolsUtils.defineLazyGetter(
|
|
lazy,
|
|
"DevToolsSocketStatus",
|
|
() =>
|
|
ChromeUtils.importESModule(
|
|
"resource://devtools/shared/security/DevToolsSocketStatus.sys.mjs",
|
|
// DevToolsSocketStatus is also accessed by non-devtools modules and
|
|
// should be loaded in the regular / shared global.
|
|
{ global: "shared" }
|
|
).DevToolsSocketStatus
|
|
);
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"EventEmitter",
|
|
"resource://devtools/shared/event-emitter.js"
|
|
);
|
|
|
|
DevToolsUtils.defineLazyGetter(this, "nsFile", () => {
|
|
return Components.Constructor(
|
|
"@mozilla.org/file/local;1",
|
|
"nsIFile",
|
|
"initWithPath"
|
|
);
|
|
});
|
|
|
|
DevToolsUtils.defineLazyGetter(this, "socketTransportService", () => {
|
|
return Cc["@mozilla.org/network/socket-transport-service;1"].getService(
|
|
Ci.nsISocketTransportService
|
|
);
|
|
});
|
|
|
|
var DebuggerSocket = {};
|
|
|
|
/**
|
|
* Connects to a devtools server socket.
|
|
*
|
|
* @param host string
|
|
* The host name or IP address of the devtools server.
|
|
* @param port number
|
|
* The port number of the devtools server.
|
|
* @param webSocket boolean (optional)
|
|
* Whether to use WebSocket protocol to connect. Defaults to false.
|
|
* @param authenticator Authenticator (optional)
|
|
* |Authenticator| instance matching the mode in use by the server.
|
|
* Defaults to a PROMPT instance if not supplied.
|
|
* @return promise
|
|
* Resolved to a DebuggerTransport instance.
|
|
*/
|
|
DebuggerSocket.connect = async function (settings) {
|
|
// Default to PROMPT |Authenticator| instance if not supplied
|
|
if (!settings.authenticator) {
|
|
settings.authenticator = new (Authenticators.get().Client)();
|
|
}
|
|
_validateSettings(settings);
|
|
// eslint-disable-next-line no-shadow
|
|
const { host, port, authenticator } = settings;
|
|
const transport = await _getTransport(settings);
|
|
await authenticator.authenticate({
|
|
host,
|
|
port,
|
|
transport,
|
|
});
|
|
transport.connectionSettings = settings;
|
|
return transport;
|
|
};
|
|
|
|
/**
|
|
* Validate that the connection settings have been set to a supported configuration.
|
|
*/
|
|
function _validateSettings(settings) {
|
|
const { authenticator } = settings;
|
|
|
|
authenticator.validateSettings(settings);
|
|
}
|
|
|
|
/**
|
|
* Try very hard to create a DevTools transport, potentially making several
|
|
* connect attempts in the process.
|
|
*
|
|
* @param host string
|
|
* The host name or IP address of the devtools server.
|
|
* @param port number
|
|
* The port number of the devtools server.
|
|
* @param webSocket boolean (optional)
|
|
* Whether to use WebSocket protocol to connect to the server. Defaults to false.
|
|
* @param authenticator Authenticator
|
|
* |Authenticator| instance matching the mode in use by the server.
|
|
* Defaults to a PROMPT instance if not supplied.
|
|
* @return transport DebuggerTransport
|
|
* A possible DevTools transport (if connection succeeded and streams
|
|
* are actually alive and working)
|
|
*/
|
|
var _getTransport = async function (settings) {
|
|
const { host, port, webSocket } = settings;
|
|
|
|
if (webSocket) {
|
|
// Establish a connection and wait until the WebSocket is ready to send and receive
|
|
const socket = await new Promise((resolve, reject) => {
|
|
const s = new WebSocket(`ws://${host}:${port}`);
|
|
s.onopen = () => resolve(s);
|
|
s.onerror = err => reject(err);
|
|
});
|
|
|
|
return new WebSocketDebuggerTransport(socket);
|
|
}
|
|
|
|
const attempt = await _attemptTransport(settings);
|
|
if (attempt.transport) {
|
|
// Success
|
|
return attempt.transport;
|
|
}
|
|
|
|
throw new Error("Connection failed");
|
|
};
|
|
|
|
/**
|
|
* Make a single attempt to connect and create a DevTools transport.
|
|
*
|
|
* @param host string
|
|
* The host name or IP address of the devtools server.
|
|
* @param port number
|
|
* The port number of the devtools server.
|
|
* @param authenticator Authenticator
|
|
* |Authenticator| instance matching the mode in use by the server.
|
|
* Defaults to a PROMPT instance if not supplied.
|
|
* @return transport DebuggerTransport
|
|
* A possible DevTools transport (if connection succeeded and streams
|
|
* are actually alive and working)
|
|
* @return s nsISocketTransport
|
|
* Underlying socket transport, in case more details are needed.
|
|
*/
|
|
var _attemptTransport = async function (settings) {
|
|
const { authenticator } = settings;
|
|
// _attemptConnect only opens the streams. Any failures at that stage
|
|
// aborts the connection process immedidately.
|
|
const { s, input, output } = await _attemptConnect(settings);
|
|
|
|
// Check if the input stream is alive.
|
|
let alive;
|
|
try {
|
|
const results = await _isInputAlive(input);
|
|
alive = results.alive;
|
|
} catch (e) {
|
|
// For other unexpected errors, like NS_ERROR_CONNECTION_REFUSED, we reach
|
|
// this block.
|
|
input.close();
|
|
output.close();
|
|
throw e;
|
|
}
|
|
|
|
// The |Authenticator| examines the connection as well and may determine it
|
|
// should be dropped.
|
|
alive =
|
|
alive &&
|
|
authenticator.validateConnection({
|
|
host: settings.host,
|
|
port: settings.port,
|
|
socket: s,
|
|
});
|
|
|
|
let transport;
|
|
if (alive) {
|
|
transport = new DebuggerTransport(input, output);
|
|
} else {
|
|
// Something went wrong, close the streams.
|
|
input.close();
|
|
output.close();
|
|
}
|
|
|
|
return { transport, s };
|
|
};
|
|
|
|
/**
|
|
* Try to connect to a remote server socket.
|
|
*
|
|
* If successsful, the socket transport and its opened streams are returned.
|
|
* Typically, this will only fail if the host / port is unreachable. Other
|
|
* problems, such as security errors, will allow this stage to succeed, but then
|
|
* fail later when the streams are actually used.
|
|
* @return s nsISocketTransport
|
|
* Underlying socket transport, in case more details are needed.
|
|
* @return input nsIAsyncInputStream
|
|
* The socket's input stream.
|
|
* @return output nsIAsyncOutputStream
|
|
* The socket's output stream.
|
|
*/
|
|
var _attemptConnect = async function ({ host, port }) {
|
|
const s = socketTransportService.createTransport([], host, port, null, null);
|
|
|
|
// Force disabling IPV6 if we aren't explicitely connecting to an IPv6 address
|
|
// It fails intermitently on MacOS when opening the Browser Toolbox (bug 1615412)
|
|
if (!host.includes(":")) {
|
|
s.connectionFlags |= Ci.nsISocketTransport.DISABLE_IPV6;
|
|
}
|
|
|
|
// By default the CONNECT socket timeout is very long, 65535 seconds,
|
|
// so that if we race to be in CONNECT state while the server socket is still
|
|
// initializing, the connection is stuck in connecting state for 18.20 hours!
|
|
s.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, 2);
|
|
|
|
let input;
|
|
let output;
|
|
return new Promise((resolve, reject) => {
|
|
s.setEventSink(
|
|
{
|
|
onTransportStatus(transport, status) {
|
|
if (status != Ci.nsISocketTransport.STATUS_CONNECTING_TO) {
|
|
return;
|
|
}
|
|
try {
|
|
input = s.openInputStream(0, 0, 0);
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
resolve({ s, input, output });
|
|
},
|
|
},
|
|
Services.tm.currentThread
|
|
);
|
|
|
|
// openOutputStream may throw NS_ERROR_NOT_INITIALIZED if we hit some race
|
|
// where the nsISocketTransport gets shutdown in between its instantiation and
|
|
// the call to this method.
|
|
try {
|
|
output = s.openOutputStream(0, 0, 0);
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}).catch(e => {
|
|
if (input) {
|
|
input.close();
|
|
}
|
|
if (output) {
|
|
output.close();
|
|
}
|
|
DevToolsUtils.reportException("_attemptConnect", e);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Check if the input stream is alive.
|
|
*/
|
|
function _isInputAlive(input) {
|
|
return new Promise((resolve, reject) => {
|
|
input.asyncWait(
|
|
{
|
|
onInputStreamReady(stream) {
|
|
try {
|
|
stream.available();
|
|
resolve({ alive: true });
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
},
|
|
},
|
|
0,
|
|
0,
|
|
Services.tm.currentThread
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates a new socket listener for remote connections to the DevToolsServer.
|
|
* This helps contain and organize the parts of the server that may differ or
|
|
* are particular to one given listener mechanism vs. another.
|
|
* This can be closed at any later time by calling |close|.
|
|
* If remote connections are disabled, an error is thrown.
|
|
*
|
|
* @param {DevToolsServer} devToolsServer
|
|
* @param {Object} socketOptions
|
|
* options of socket as follows
|
|
* {
|
|
* authenticator:
|
|
* Controls the |Authenticator| used, which hooks various socket steps to
|
|
* implement an authentication policy. It is expected that different use
|
|
* cases may override pieces of the |Authenticator|. See auth.js.
|
|
* We set the default |Authenticator|, which is |Prompt|.
|
|
* discoverable:
|
|
* Controls whether this listener is announced via the service discovery
|
|
* mechanism. Defaults is false.
|
|
* fromBrowserToolbox:
|
|
* Should only be passed when opening a socket for a Browser Toolbox
|
|
* session. DevToolsSocketStatus will track the socket separately to
|
|
* avoid triggering the visual cue in the URL bar.
|
|
* portOrPath:
|
|
* The port or path to listen on.
|
|
* If given an integer, the port to listen on. Use -1 to choose any available
|
|
* port. Otherwise, the path to the unix socket domain file to listen on.
|
|
* Defaults is null.
|
|
* webSocket:
|
|
* Whether to use WebSocket protocol. Defaults is false.
|
|
* }
|
|
*/
|
|
function SocketListener(devToolsServer, socketOptions) {
|
|
this._devToolsServer = devToolsServer;
|
|
|
|
// Set socket options with default value
|
|
this._socketOptions = {
|
|
authenticator:
|
|
socketOptions.authenticator || new (Authenticators.get().Server)(),
|
|
discoverable: !!socketOptions.discoverable,
|
|
fromBrowserToolbox: !!socketOptions.fromBrowserToolbox,
|
|
portOrPath: socketOptions.portOrPath || null,
|
|
webSocket: !!socketOptions.webSocket,
|
|
};
|
|
|
|
EventEmitter.decorate(this);
|
|
}
|
|
|
|
SocketListener.prototype = {
|
|
get authenticator() {
|
|
return this._socketOptions.authenticator;
|
|
},
|
|
|
|
get discoverable() {
|
|
return this._socketOptions.discoverable;
|
|
},
|
|
|
|
get fromBrowserToolbox() {
|
|
return this._socketOptions.fromBrowserToolbox;
|
|
},
|
|
|
|
get portOrPath() {
|
|
return this._socketOptions.portOrPath;
|
|
},
|
|
|
|
get webSocket() {
|
|
return this._socketOptions.webSocket;
|
|
},
|
|
|
|
/**
|
|
* Validate that all options have been set to a supported configuration.
|
|
*/
|
|
_validateOptions() {
|
|
if (this.portOrPath === null) {
|
|
throw new Error("Must set a port / path to listen on.");
|
|
}
|
|
if (this.discoverable && !Number(this.portOrPath)) {
|
|
throw new Error("Discovery only supported for TCP sockets.");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Listens on the given port or socket file for remote debugger connections.
|
|
*/
|
|
open() {
|
|
this._validateOptions();
|
|
this._devToolsServer.addSocketListener(this);
|
|
|
|
let flags = Ci.nsIServerSocket.KeepWhenOffline;
|
|
// A preference setting can force binding on the loopback interface.
|
|
if (Services.prefs.getBoolPref("devtools.debugger.force-local")) {
|
|
flags |= Ci.nsIServerSocket.LoopbackOnly;
|
|
}
|
|
|
|
const self = this;
|
|
return (async function () {
|
|
const backlog = 4;
|
|
self._socket = self._createSocketInstance();
|
|
if (self.isPortBased) {
|
|
const port = Number(self.portOrPath);
|
|
self._socket.initSpecialConnection(port, flags, backlog);
|
|
} else if (self.portOrPath.startsWith("/")) {
|
|
const file = nsFile(self.portOrPath);
|
|
if (file.exists()) {
|
|
file.remove(false);
|
|
}
|
|
self._socket.initWithFilename(file, parseInt("666", 8), backlog);
|
|
} else {
|
|
// Path isn't absolute path, so we use abstract socket address
|
|
self._socket.initWithAbstractAddress(self.portOrPath, backlog);
|
|
}
|
|
self._socket.asyncListen(self);
|
|
dumpn("Socket listening on: " + (self.port || self.portOrPath));
|
|
})()
|
|
.then(() => {
|
|
lazy.DevToolsSocketStatus.notifySocketOpened({
|
|
fromBrowserToolbox: self.fromBrowserToolbox,
|
|
});
|
|
this._advertise();
|
|
})
|
|
.catch(e => {
|
|
dumpn(
|
|
"Could not start debugging listener on '" +
|
|
this.portOrPath +
|
|
"': " +
|
|
e
|
|
);
|
|
this.close();
|
|
});
|
|
},
|
|
|
|
_advertise() {
|
|
if (!this.discoverable || !this.port) {
|
|
return;
|
|
}
|
|
|
|
const advertisement = {
|
|
port: this.port,
|
|
};
|
|
|
|
this.authenticator.augmentAdvertisement(this, advertisement);
|
|
|
|
discovery.addService("devtools", advertisement);
|
|
},
|
|
|
|
_createSocketInstance() {
|
|
return Cc["@mozilla.org/network/server-socket;1"].createInstance(
|
|
Ci.nsIServerSocket
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Closes the SocketListener. Notifies the server to remove the listener from
|
|
* the set of active SocketListeners.
|
|
*/
|
|
close() {
|
|
if (this.discoverable && this.port) {
|
|
discovery.removeService("devtools");
|
|
}
|
|
if (this._socket) {
|
|
this._socket.close();
|
|
this._socket = null;
|
|
|
|
lazy.DevToolsSocketStatus.notifySocketClosed({
|
|
fromBrowserToolbox: this.fromBrowserToolbox,
|
|
});
|
|
}
|
|
this._devToolsServer.removeSocketListener(this);
|
|
},
|
|
|
|
get host() {
|
|
if (!this._socket) {
|
|
return null;
|
|
}
|
|
if (Services.prefs.getBoolPref("devtools.debugger.force-local")) {
|
|
return "127.0.0.1";
|
|
}
|
|
return "0.0.0.0";
|
|
},
|
|
|
|
/**
|
|
* Gets whether this listener uses a port number vs. a path.
|
|
*/
|
|
get isPortBased() {
|
|
return !!Number(this.portOrPath);
|
|
},
|
|
|
|
/**
|
|
* Gets the port that a TCP socket listener is listening on, or null if this
|
|
* is not a TCP socket (so there is no port).
|
|
*/
|
|
get port() {
|
|
if (!this.isPortBased || !this._socket) {
|
|
return null;
|
|
}
|
|
return this._socket.port;
|
|
},
|
|
|
|
onAllowedConnection(transport) {
|
|
dumpn("onAllowedConnection, transport: " + transport);
|
|
this.emit("accepted", transport, this);
|
|
},
|
|
|
|
// nsIServerSocketListener implementation
|
|
|
|
onSocketAccepted: DevToolsUtils.makeInfallible(function (
|
|
socket,
|
|
socketTransport
|
|
) {
|
|
const connection = new ServerSocketConnection(this, socketTransport);
|
|
connection.once("allowed", this.onAllowedConnection.bind(this));
|
|
}, "SocketListener.onSocketAccepted"),
|
|
|
|
onStopListening(socket, status) {
|
|
dumpn("onStopListening, status: " + status);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* A |ServerSocketConnection| is created by a |SocketListener| for each accepted
|
|
* incoming socket.
|
|
*/
|
|
function ServerSocketConnection(listener, socketTransport) {
|
|
this._listener = listener;
|
|
this._socketTransport = socketTransport;
|
|
this._handle();
|
|
EventEmitter.decorate(this);
|
|
}
|
|
|
|
ServerSocketConnection.prototype = {
|
|
get authentication() {
|
|
return this._listener.authenticator.mode;
|
|
},
|
|
|
|
get host() {
|
|
return this._socketTransport.host;
|
|
},
|
|
|
|
get port() {
|
|
return this._socketTransport.port;
|
|
},
|
|
|
|
get address() {
|
|
return this.host + ":" + this.port;
|
|
},
|
|
|
|
get client() {
|
|
const client = {
|
|
host: this.host,
|
|
port: this.port,
|
|
};
|
|
return client;
|
|
},
|
|
|
|
get server() {
|
|
const server = {
|
|
host: this._listener.host,
|
|
port: this._listener.port,
|
|
};
|
|
return server;
|
|
},
|
|
|
|
/**
|
|
* This is the main authentication workflow. If any pieces reject a promise,
|
|
* the connection is denied. If the entire process resolves successfully,
|
|
* the connection is finally handed off to the |DevToolsServer|.
|
|
*/
|
|
async _handle() {
|
|
dumpn("Debugging connection starting authentication on " + this.address);
|
|
try {
|
|
await this._createTransport();
|
|
await this._authenticate();
|
|
this.allow();
|
|
} catch (e) {
|
|
this.deny(e);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* We need to open the streams early on, as that is required in the case of
|
|
* TLS sockets to keep the handshake moving.
|
|
*/
|
|
async _createTransport() {
|
|
const input = this._socketTransport.openInputStream(0, 0, 0);
|
|
const output = this._socketTransport.openOutputStream(0, 0, 0);
|
|
|
|
if (this._listener.webSocket) {
|
|
const socket = await WebSocketServer.accept(
|
|
this._socketTransport,
|
|
input,
|
|
output
|
|
);
|
|
this._transport = new WebSocketDebuggerTransport(socket);
|
|
} else {
|
|
this._transport = new DebuggerTransport(input, output);
|
|
}
|
|
|
|
// Start up the transport to observe the streams in case they are closed
|
|
// early. This allows us to clean up our state as well.
|
|
this._transport.hooks = {
|
|
onTransportClosed: reason => {
|
|
this.deny(reason);
|
|
},
|
|
};
|
|
this._transport.ready();
|
|
},
|
|
|
|
async _authenticate() {
|
|
const result = await this._listener.authenticator.authenticate({
|
|
client: this.client,
|
|
server: this.server,
|
|
transport: this._transport,
|
|
});
|
|
|
|
// If result is fine, we can stop here
|
|
if (
|
|
result === AuthenticationResult.ALLOW ||
|
|
result === AuthenticationResult.ALLOW_PERSIST
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (result === AuthenticationResult.DISABLE_ALL) {
|
|
this._listener._devToolsServer.closeAllSocketListeners();
|
|
Services.prefs.setBoolPref("devtools.debugger.remote-enabled", false);
|
|
}
|
|
|
|
// If we got an error (DISABLE_ALL, DENY, …), let's throw a NS_ERROR_CONNECTION_REFUSED
|
|
// exception
|
|
throw Components.Exception("", Cr.NS_ERROR_CONNECTION_REFUSED);
|
|
},
|
|
|
|
deny(result) {
|
|
if (this._destroyed) {
|
|
return;
|
|
}
|
|
let errorName = result;
|
|
for (const name in Cr) {
|
|
if (Cr[name] === result) {
|
|
errorName = name;
|
|
break;
|
|
}
|
|
}
|
|
dumpn(
|
|
"Debugging connection denied on " + this.address + " (" + errorName + ")"
|
|
);
|
|
if (this._transport) {
|
|
this._transport.hooks = null;
|
|
this._transport.close(result);
|
|
}
|
|
this._socketTransport.close(result);
|
|
this.destroy();
|
|
},
|
|
|
|
allow() {
|
|
if (this._destroyed) {
|
|
return;
|
|
}
|
|
dumpn("Debugging connection allowed on " + this.address);
|
|
this.emit("allowed", this._transport);
|
|
this.destroy();
|
|
},
|
|
|
|
destroy() {
|
|
this._destroyed = true;
|
|
this._listener = null;
|
|
this._socketTransport = null;
|
|
this._transport = null;
|
|
},
|
|
};
|
|
|
|
exports.DebuggerSocket = DebuggerSocket;
|
|
exports.SocketListener = SocketListener;
|