Verified Commit 45e1dd66 authored by Pier Angelo Vendrame's avatar Pier Angelo Vendrame 🎃
Browse files

fixup! Bug 40933: Add tor-launcher functionality

Moved the control port parsing for asynchronous events from TorProvider
to TorControlPort.
parent df87cd7c
Loading
Loading
Loading
Loading
+128 −40
Original line number Diff line number Diff line
@@ -309,20 +309,22 @@ export class TorController {
  #commandQueue = [];

  /**
   * The callbacks for handling async notifications.
   * The event handler.
   *
   * @type {Map<string, EventCallback>}
   * @type {TorEventHandler}
   */
  #eventCallbacks = new Map();
  #eventHandler;

  /**
   * Connect to a control port over a Unix socket.
   * Not available on Windows.
   *
   * @param {nsIFile} ipcFile The path to the Unix socket to connect to
   * @param {TorEventHandler} eventHandler The event handler to use for
   * asynchronous notifications
   */
  static fromIpcFile(ipcFile) {
    return new TorController(AsyncSocket.fromIpcFile(ipcFile));
  static fromIpcFile(ipcFile, eventHandler) {
    return new TorController(AsyncSocket.fromIpcFile(ipcFile), eventHandler);
  }

  /**
@@ -330,9 +332,14 @@ export class TorController {
   *
   * @param {string} host The hostname to connect to
   * @param {number} port The port to connect the to
   * @param {TorEventHandler} eventHandler The event handler to use for
   * asynchronous notifications
   */
  static fromSocketAddress(host, port) {
    return new TorController(AsyncSocket.fromSocketAddress(host, port));
  static fromSocketAddress(host, port, eventHandler) {
    return new TorController(
      AsyncSocket.fromSocketAddress(host, port),
      eventHandler
    );
  }

  /**
@@ -343,9 +350,12 @@ export class TorController {
   *
   * @private
   * @param {AsyncSocket} socket The socket to use
   * @param {TorEventHandler} eventHandler The event handler to use for
   * asynchronous notifications
   */
  constructor(socket) {
  constructor(socket, eventHandler) {
    this.#socket = socket;
    this.#eventHandler = eventHandler;
    this.#startMessagePump();
  }

@@ -449,24 +459,6 @@ export class TorController {
    }
  }

  /**
   * Re-route an event message to the notification dispatcher.
   *
   * @param {string} message The message received on the control port. It should
   * starts with `"650" SP`.
   */
  #handleNotification(message) {
    const maybeType = message.match(/^650\s+(?<type>\S+)/);
    const callback = this.#eventCallbacks.get(maybeType?.groups.type);
    if (callback) {
      try {
        callback(message);
      } catch (e) {
        console.error("An event watcher threw", e);
      }
    }
  }

  /**
   * Read messages on the socket and routed them to a dispatcher until the
   * socket is open or some error happens (including the underlying socket being
@@ -479,11 +471,18 @@ export class TorController {
      // condition becoming false.
      while (this.#socket) {
        const message = await this.#readMessage();
        try {
          if (message.startsWith("650")) {
            this.#handleNotification(message);
          } else {
            this.#handleCommandReply(message);
          }
        } catch (err) {
          // E.g., if a notification handler fails. Without this internal
          // try/catch we risk of closing the connection while not actually
          // needed.
          console.error("Caught an exception while handling a message", err);
        }
      }
    } catch (err) {
      try {
@@ -955,18 +954,76 @@ export class TorController {
  }

  /**
   * Watches for a particular type of asynchronous event.
   * Notice: we only observe `"650" SP...` events, currently (no `650+...` or
   * `650-...` events).
   * Also, you need to enable the events in the control port with SETEVENTS,
   * first.
   * Parse an asynchronous event and pass the data to the relative handler.
   * Only single-line messages are currently supported.
   *
   * @param {string} type The event type to catch
   * @param {EventCallback} callback The callback that will handle the event
   * @param {string} message The message received on the control port. It should
   * starts with `"650" SP`.
   */
  watchEvent(type, callback) {
    this.#expectString(type, "type");
    this.#eventCallbacks.set(type, callback);
  #handleNotification(message) {
    if (!this.#eventHandler) {
      return;
    }
    const data = message.match(/^650\s+(?<type>\S+)\s*(?<data>.*)?/);
    if (!data) {
      return;
    }
    switch (data.groups.type) {
      case "STATUS_CLIENT":
        let status;
        try {
          status = this.#parseBootstrapStatus(data.groups.data);
        } catch {
          // Probably, a non bootstrap client status
          break;
        }
        this.#eventHandler.onBootstrapStatus(status);
        break;
      case "CIRC":
        const builtEvent =
          /^(?<ID>[a-zA-Z0-9]{1,16})\sBUILT\s(?<Path>(,?\$([0-9a-fA-F]{40})(?:~[a-zA-Z0-9]{1,19})?)+)/.exec(
            data.groups.data
          );
        const closedEvent = /^(?<ID>[a-zA-Z0-9]{1,16})\sCLOSED/.exec(
          data.groups.data
        );
        if (builtEvent) {
          const fp = /\$([0-9a-fA-F]{40})/g;
          const nodes = Array.from(builtEvent.groups.Path.matchAll(fp), g =>
            g[1].toUpperCase()
          );
          // In some cases, we might already receive SOCKS credentials in the
          // line. However, this might be a problem with onion services: we get
          // also a 4-hop circuit that we likely do not want to show to the
          // user, especially because it is used only temporarily, and it would
          // need a technical explaination.
          // const credentials = this.#parseCredentials(data.groups.data);
          this.#eventHandler.onCircuitBuilt(builtEvent.groups.ID, nodes);
        } else if (closedEvent) {
          this.#eventHandler.onCircuitClosed(closedEvent.groups.ID);
        }
        break;
      case "STREAM":
        const succeeedEvent =
          /^(?<StreamID>[a-zA-Z0-9]{1,16})\sSUCCEEDED\s(?<CircuitID>[a-zA-Z0-9]{1,16})/.exec(
            data.groups.data
          );
        if (succeeedEvent) {
          const credentials = this.#parseCredentials(data.groups.data);
          this.#eventHandler.onStreamSucceeded(
            succeeedEvent.groups.StreamID,
            succeeedEvent.groups.CircuitID,
            credentials?.username ?? null,
            credentials?.password ?? null
          );
        }
        break;
      case "NOTICE":
      case "WARN":
      case "ERR":
        this.#eventHandler.onLogMessage(data.groups.type, data.groups.data);
        break;
    }
  }

  // Other helpers
@@ -1011,6 +1068,24 @@ export class TorController {
    }
  }

  /**
   * Check if a STREAM or CIRC response line contains SOCKS_USERNAME and
   * SOCKS_PASSWORD.
   *
   * @param {string} line The circ or stream line to check
   * @returns {object?} The credentials, or null if not found
   */
  #parseCredentials(line) {
    const username = /SOCKS_USERNAME=("(?:[^"\\]|\\.)*")/.exec(line);
    const password = /SOCKS_PASSWORD=("(?:[^"\\]|\\.)*")/.exec(line);
    return username && password
      ? {
          username: TorParsers.unescapeString(username[1]),
          password: TorParsers.unescapeString(password[1]),
        }
      : null;
  }

  /**
   * Return an object with all the matches that are in the form `key="value"` or
   * `key=value`. The values will be unescaped, but no additional parsing will
@@ -1030,3 +1105,16 @@ export class TorController {
    );
  }
}

/**
 * The event handler interface.
 * The controller owner can implement this methods to receive asynchronous
 * notifications from the controller.
 */
export class TorEventHandler {
  onBootstrapStatus(status) {}
  onLogMessage(message) {}
  onCircuitBuilt(id, nodes) {}
  onCircuitClosed(id) {}
  onStreamSucceeded(streamId, circuitId, username, password) {}
}
+0 −165
Original line number Diff line number Diff line
@@ -6,171 +6,6 @@ export const TorStatuses = Object.freeze({
});

export const TorParsers = Object.freeze({
  commandSucceeded(aReply) {
    return aReply?.statusCode === TorStatuses.OK;
  },

  // parseReply() understands simple GETCONF and GETINFO replies.
  parseReply(aCmd, aKey, aReply) {
    if (!aCmd || !aKey || !aReply || !aReply.lineArray?.length) {
      return [];
    }

    const lcKey = aKey.toLowerCase();
    const prefix = lcKey + "=";
    const prefixLen = prefix.length;
    const tmpArray = [];
    for (const line of aReply.lineArray) {
      var lcLine = line.toLowerCase();
      if (lcLine === lcKey) {
        tmpArray.push("");
      } else if (lcLine.indexOf(prefix) !== 0) {
        console.warn(`Unexpected ${aCmd} response: ${line}`);
      } else {
        try {
          let s = this.unescapeString(line.substring(prefixLen));
          tmpArray.push(s);
        } catch (e) {
          console.warn(
            `Error while unescaping the response of ${aCmd}: ${line}`,
            e
          );
        }
      }
    }

    return tmpArray;
  },

  // Returns false if more lines are needed.  The first time, callers
  // should pass an empty aReplyObj.
  // Parsing errors are indicated by aReplyObj._parseError = true.
  parseReplyLine(aLine, aReplyObj) {
    if (!aLine || !aReplyObj) {
      return false;
    }

    if (!("_parseError" in aReplyObj)) {
      aReplyObj.statusCode = 0;
      aReplyObj.lineArray = [];
      aReplyObj._parseError = false;
    }

    if (aLine.length < 4) {
      console.error("Unexpected response: ", aLine);
      aReplyObj._parseError = true;
      return true;
    }

    // TODO: handle + separators (data)
    aReplyObj.statusCode = parseInt(aLine.substring(0, 3), 10);
    const s = aLine.length < 5 ? "" : aLine.substring(4);
    // Include all lines except simple "250 OK" ones.
    if (aReplyObj.statusCode !== TorStatuses.OK || s !== "OK") {
      aReplyObj.lineArray.push(s);
    }

    return aLine.charAt(3) === " ";
  },

  // Split aStr at spaces, accounting for quoted values.
  // Returns an array of strings.
  splitReplyLine(aStr) {
    // Notice: the original function did not check for escaped quotes.
    return aStr
      .split('"')
      .flatMap((token, index) => {
        const inQuotedStr = index % 2 === 1;
        return inQuotedStr ? `"${token}"` : token.split(" ");
      })
      .filter(s => s);
  },

  // Helper function for converting a raw controller response into a parsed object.
  parseCommandResponse(reply) {
    if (!reply) {
      return {};
    }
    const lines = reply.split("\r\n");
    const rv = {};
    for (const line of lines) {
      if (this.parseReplyLine(line, rv) || rv._parseError) {
        break;
      }
    }
    return rv;
  },

  // If successful, returns a JS object with these fields:
  //   status.TYPE            -- "NOTICE" or "WARN"
  //   status.PROGRESS        -- integer
  //   status.TAG             -- string
  //   status.SUMMARY         -- string
  //   status.WARNING         -- string (optional)
  //   status.REASON          -- string (optional)
  //   status.COUNT           -- integer (optional)
  //   status.RECOMMENDATION  -- string (optional)
  //   status.HOSTADDR        -- string (optional)
  // Returns null upon failure.
  parseBootstrapStatus(aStatusMsg) {
    if (!aStatusMsg || !aStatusMsg.length) {
      return null;
    }

    let sawBootstrap = false;
    const statusObj = {};
    statusObj.TYPE = "NOTICE";

    // The following code assumes that this is a one-line response.
    for (const tokenAndVal of this.splitReplyLine(aStatusMsg)) {
      let token, val;
      const idx = tokenAndVal.indexOf("=");
      if (idx < 0) {
        token = tokenAndVal;
      } else {
        token = tokenAndVal.substring(0, idx);
        try {
          val = TorParsers.unescapeString(tokenAndVal.substring(idx + 1));
        } catch (e) {
          console.debug("Could not parse the token value", e);
        }
        if (!val) {
          // skip this token/value pair.
          continue;
        }
      }

      switch (token) {
        case "BOOTSTRAP":
          sawBootstrap = true;
          break;
        case "WARN":
        case "NOTICE":
        case "ERR":
          statusObj.TYPE = token;
          break;
        case "COUNT":
        case "PROGRESS":
          statusObj[token] = parseInt(val, 10);
          break;
        default:
          statusObj[token] = val;
          break;
      }
    }

    if (!sawBootstrap) {
      if (statusObj.TYPE === "NOTICE") {
        console.info(aStatusMsg);
      } else {
        console.warn(aStatusMsg);
      }
      return null;
    }

    return statusObj;
  },

  // Escape non-ASCII characters for use within the Tor Control protocol.
  // Based on Vidalia's src/common/stringutil.cpp:string_escape().
  // Returns the new string.
+20 −142
Original line number Diff line number Diff line
@@ -6,10 +6,6 @@ import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
import { ConsoleAPI } from "resource://gre/modules/Console.sys.mjs";

import { TorLauncherUtil } from "resource://gre/modules/TorLauncherUtil.sys.mjs";
import {
  TorParsers,
  TorStatuses,
} from "resource://gre/modules/TorParsers.sys.mjs";
import { TorProviderTopics } from "resource://gre/modules/TorProviderBuilder.sys.mjs";

const lazy = {};
@@ -449,14 +445,7 @@ export class TorProvider {
    };
    this.#torProcess.onRestart = async () => {
      logger.info("Restarting the tor process");
      try {
        this.#controlConnection.close();
      } catch (e) {
        logger.warn(
          "Error when closing the previos control port on restart",
          e
        );
      }
      this.#closeConnection();
      this.#isBootstrapDone = false;
      this.#lastWarning = {};
      this.#circuits.clear();
@@ -597,24 +586,20 @@ export class TorProvider {
  }

  async #setupEvents() {
    // We always liten to these events, because they are needed for the circuit
    // We always listen to these events, because they are needed for the circuit
    // display.
    this.#eventHandlers = new Map([
      ["CIRC", this.#processCircEvent.bind(this)],
      ["STREAM", this.#processStreamEvent.bind(this)],
    ]);
    const events = ["CIRC", "STREAM"];
    if (this.ownsTorDaemon) {
      // When we own the tor daemon, we listen to more events, that are used
      // for about:torconnect or for showing the logs in the settings page.
      this.#eventHandlers.set(
        "STATUS_CLIENT",
        this.#processStatusClient.bind(this)
      events.push("STATUS_CLIENT", "NOTICE", "WARN", "ERR");
      // Do not await on the first bootstrap status retrieval, and do not
      // propagate its errors.
      this.#controlConnection
        .getBootstrapPhase()
        .then(status => this.#processBootstrapStatus(status, false))
        .catch(e =>
          logger.error("Failed to get the first bootstrap status", e)
        );
      this.#eventHandlers.set("NOTICE", this.#processLog.bind(this));
      this.#eventHandlers.set("WARN", this.#processLog.bind(this));
      this.#eventHandlers.set("ERR", this.#processLog.bind(this));
    }
    const events = Array.from(this.#eventHandlers.keys());
    try {
      logger.debug(`Setting events: ${events.join(" ")}`);
      await this.#controlConnection.setEvents(events);
@@ -623,10 +608,6 @@ export class TorProvider {
        "We could not enable all the events we need. Tor Browser's functionalities might be reduced.",
        e
      );
      return;
    }
    for (const [type, callback] of this.#eventHandlers.entries()) {
      this.#monitorEvent(type, callback);
    }
  }

@@ -641,12 +622,14 @@ export class TorProvider {
    let controlPort;
    if (this.#controlPortSettings.ipcFile) {
      controlPort = lazy.TorController.fromIpcFile(
        this.#controlPortSettings.ipcFile
        this.#controlPortSettings.ipcFile,
        this
      );
    } else {
      controlPort = lazy.TorController.fromSocketAddress(
        this.#controlPortSettings.host,
        this.#controlPortSettings.port
        this.#controlPortSettings.port,
        this
      );
    }
    try {
@@ -681,6 +664,10 @@ export class TorProvider {
        logger.error("Failed to close the control port connection", e);
      }
      this.#controlConnection = null;
    } else {
      logger.trace(
        "Requested to close an already closed control port connection"
      );
    }
  }

@@ -901,113 +888,4 @@ export class TorProvider {
      TorProviderTopics.StreamSucceeded
    );
  }

  // TODO: These are all parsing functions that should be moved to
  // TorControlPort.

  #eventHandlers = null;

  #monitorEvent(type, callback) {
    logger.info(`Watching events of type ${type}.`);
    let replyObj = {};
    this.#controlConnection.watchEvent(type, line => {
      if (!line) {
        return;
      }
      logger.debug("Event response: ", line);
      const isComplete = TorParsers.parseReplyLine(line, replyObj);
      if (!isComplete || replyObj._parseError || !replyObj.lineArray.length) {
        return;
      }
      const reply = replyObj;
      replyObj = {};
      if (reply.statusCode !== TorStatuses.EventNotification) {
        logger.error("Unexpected event status code:", reply.statusCode);
        return;
      }
      if (!reply.lineArray[0].startsWith(`${type} `)) {
        logger.error("Wrong format for the first line:", reply.lineArray[0]);
        return;
      }
      reply.lineArray[0] = reply.lineArray[0].substring(type.length + 1);
      try {
        callback(type, reply.lineArray);
      } catch (e) {
        logger.error("Exception while handling an event", reply, e);
      }
    });
  }

  #processStatusClient(_type, lines) {
    const statusObj = TorParsers.parseBootstrapStatus(lines[0]);
    if (!statusObj) {
      // No `BOOTSTRAP` in the line
      return;
    }
    this.onBootstrapStatus(statusObj);
  }

  #processLog(type, lines) {
    this.onLogMessage(type, lines.join("\n"));
  }

  async #processCircEvent(_type, lines) {
    const builtEvent =
      /^(?<CircuitID>[a-zA-Z0-9]{1,16})\sBUILT\s(?<Path>(?:,?\$[0-9a-fA-F]{40}(?:~[a-zA-Z0-9]{1,19})?)+)/.exec(
        lines[0]
      );
    const closedEvent = /^(?<ID>[a-zA-Z0-9]{1,16})\sCLOSED/.exec(lines[0]);
    if (builtEvent) {
      const fp = /\$([0-9a-fA-F]{40})/g;
      const nodes = Array.from(builtEvent.groups.Path.matchAll(fp), g =>
        g[1].toUpperCase()
      );
      // In some cases, we might already receive SOCKS credentials in the line.
      // However, this might be a problem with onion services: we get also a
      // 4-hop circuit that we likely do not want to show to the user,
      // especially because it is used only temporarily, and it would need a
      // technical explaination.
      // const credentials = this.#parseCredentials(lines[0]);
      this.onCircuitBuilt(builtEvent.groups.CircuitID, nodes);
    } else if (closedEvent) {
      this.onCircuitClosed(closedEvent.groups.ID);
    }
  }

  #processStreamEvent(_type, lines) {
    const succeeedEvent =
      /^(?<StreamID>[a-zA-Z0-9]){1,16}\sSUCCEEDED\s(?<CircuitID>[a-zA-Z0-9]{1,16})/.exec(
        lines[0]
      );
    if (!succeeedEvent) {
      return;
    }
    const credentials = this.#parseCredentials(lines[0]);
    if (credentials !== null) {
      this.onStreamSucceeded(
        succeeedEvent.groups.StreamID,
        succeeedEvent.groups.CircuitID,
        credentials.username,
        credentials.password
      );
    }
  }

  /**
   * Check if a STREAM or CIRC response line contains SOCKS_USERNAME and
   * SOCKS_PASSWORD.
   *
   * @param {string} line The circ or stream line to check
   * @returns {object?} The credentials, or null if not found
   */
  #parseCredentials(line) {
    const username = /SOCKS_USERNAME=("(?:[^"\\]|\\.)*")/.exec(line);
    const password = /SOCKS_PASSWORD=("(?:[^"\\]|\\.)*")/.exec(line);
    return username && password
      ? {
          username: TorParsers.unescapeString(username[1]),
          password: TorParsers.unescapeString(password[1]),
        }
      : null;
  }
}