/* 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
  );
});

class DebuggerSocket {
  /**
   * Connects to a devtools server socket.
   *
   * @param {object} settings
   * @param {string} settings.host
   *        The host name or IP address of the devtools server.
   * @param {number} settings.port
   *        The port number of the devtools server.
   * @param {boolean} [settings.webSocket]
   *        Whether to use WebSocket protocol to connect. Defaults to false.
   * @param {Authenticator} [settings.authenticator]
   *        |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.
   */
  static async connect(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.
 */
class SocketListener extends EventEmitter {
  /**
   * @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.
   *        }
   */
  constructor(devToolsServer, socketOptions) {
    super();

    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,
    };
  }

  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.
 */
class ServerSocketConnection extends EventEmitter {
  constructor(listener, socketTransport) {
    super();

    this._listener = listener;
    this._socketTransport = socketTransport;
    this._handle();
  }
  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;
