tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

IPPProxyManager.sys.mjs (14005B)


      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 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  IPPEnrollAndEntitleManager:
      9    "moz-src:///browser/components/ipprotection/IPPEnrollAndEntitleManager.sys.mjs",
     10  IPPChannelFilter:
     11    "moz-src:///browser/components/ipprotection/IPPChannelFilter.sys.mjs",
     12  IPProtectionUsage:
     13    "moz-src:///browser/components/ipprotection/IPProtectionUsage.sys.mjs",
     14  IPPNetworkErrorObserver:
     15    "moz-src:///browser/components/ipprotection/IPPNetworkErrorObserver.sys.mjs",
     16  IPProtectionServerlist:
     17    "moz-src:///browser/components/ipprotection/IPProtectionServerlist.sys.mjs",
     18  IPProtectionService:
     19    "moz-src:///browser/components/ipprotection/IPProtectionService.sys.mjs",
     20  IPProtectionStates:
     21    "moz-src:///browser/components/ipprotection/IPProtectionService.sys.mjs",
     22 });
     23 
     24 ChromeUtils.defineLazyGetter(
     25  lazy,
     26  "setTimeout",
     27  () =>
     28    ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs")
     29      .setTimeout
     30 );
     31 ChromeUtils.defineLazyGetter(
     32  lazy,
     33  "clearTimeout",
     34  () =>
     35    ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs")
     36      .clearTimeout
     37 );
     38 
     39 import { ERRORS } from "chrome://browser/content/ipprotection/ipprotection-constants.mjs";
     40 
     41 const LOG_PREF = "browser.ipProtection.log";
     42 const MAX_ERROR_HISTORY = 50;
     43 
     44 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
     45  return console.createInstance({
     46    prefix: "IPPProxyManager",
     47    maxLogLevel: Services.prefs.getBoolPref(LOG_PREF, false) ? "Debug" : "Warn",
     48  });
     49 });
     50 
     51 /**
     52 * @typedef {object} IPPProxyStates
     53 *  List of the possible states of the IPPProxyManager.
     54 * @property {string} NOT_READY
     55 *  The proxy is not ready because the main state machine is not in the READY state.
     56 * @property {string} READY
     57 *  The proxy is ready to be activated.
     58 * @property {string} ACTIVE
     59 *  The proxy is active.
     60 * @property {string} ERROR
     61 *  Error
     62 *
     63 * Note: If you update this list of states, make sure to update the
     64 * corresponding documentation in the `docs` folder as well.
     65 */
     66 export const IPPProxyStates = Object.freeze({
     67  NOT_READY: "not-ready",
     68  READY: "ready",
     69  ACTIVATING: "activating",
     70  ACTIVE: "active",
     71  ERROR: "error",
     72 });
     73 
     74 /**
     75 * Manages the proxy connection for the IPProtectionService.
     76 */
     77 class IPPProxyManagerSingleton extends EventTarget {
     78  #state = IPPProxyStates.NOT_READY;
     79 
     80  #activatingPromise = null;
     81 
     82  #pass = null;
     83  /**@type {import("./IPPChannelFilter.sys.mjs").IPPChannelFilter | null} */
     84  #connection = null;
     85  #usageObserver = null;
     86  #networkErrorObserver = null;
     87  // If this is set, we're awaiting a proxy pass rotation
     88  #rotateProxyPassPromise = null;
     89  #activatedAt = false;
     90 
     91  #rotationTimer = 0;
     92 
     93  errors = [];
     94 
     95  constructor() {
     96    super();
     97 
     98    this.setErrorState = this.#setErrorState.bind(this);
     99    this.handleProxyErrorEvent = this.#handleProxyErrorEvent.bind(this);
    100    this.handleEvent = this.#handleEvent.bind(this);
    101  }
    102 
    103  init() {
    104    lazy.IPProtectionService.addEventListener(
    105      "IPProtectionService:StateChanged",
    106      this.handleEvent
    107    );
    108  }
    109 
    110  initOnStartupCompleted() {}
    111 
    112  uninit() {
    113    lazy.IPProtectionService.removeEventListener(
    114      "IPProtectionService:StateChanged",
    115      this.handleEvent
    116    );
    117 
    118    this.errors = [];
    119 
    120    if (
    121      this.#state === IPPProxyStates.ACTIVE ||
    122      this.#state === IPPProxyStates.ACTIVATING
    123    ) {
    124      this.stop(false);
    125    }
    126 
    127    this.reset();
    128    this.#connection = null;
    129    this.usageObserver.stop();
    130  }
    131 
    132  /**
    133   * Checks if the proxy is active and was activated.
    134   *
    135   * @returns {Date}
    136   */
    137  get activatedAt() {
    138    return this.#state === IPPProxyStates.ACTIVE && this.#activatedAt;
    139  }
    140 
    141  get usageObserver() {
    142    if (!this.#usageObserver) {
    143      this.#usageObserver = new lazy.IPProtectionUsage();
    144    }
    145    return this.#usageObserver;
    146  }
    147 
    148  get networkErrorObserver() {
    149    if (!this.#networkErrorObserver) {
    150      this.#networkErrorObserver = new lazy.IPPNetworkErrorObserver();
    151      this.#networkErrorObserver.addEventListener(
    152        "proxy-http-error",
    153        this.handleProxyErrorEvent
    154      );
    155    }
    156    return this.#networkErrorObserver;
    157  }
    158 
    159  get active() {
    160    return this.#state === IPPProxyStates.ACTIVE;
    161  }
    162 
    163  get isolationKey() {
    164    return this.#connection?.isolationKey;
    165  }
    166 
    167  get hasValidProxyPass() {
    168    return !!this.#pass?.isValid();
    169  }
    170 
    171  createChannelFilter() {
    172    if (!this.#connection) {
    173      this.#connection = lazy.IPPChannelFilter.create();
    174      this.#connection.start();
    175    }
    176  }
    177 
    178  cancelChannelFilter() {
    179    if (this.#connection) {
    180      this.#connection.stop();
    181      this.#connection = null;
    182    }
    183  }
    184 
    185  get state() {
    186    return this.#state;
    187  }
    188 
    189  /**
    190   * Start the proxy if the user is eligible.
    191   *
    192   * @param {boolean} userAction
    193   * True if started by user action, false if system action
    194   */
    195  async start(userAction = true) {
    196    if (this.#state === IPPProxyStates.NOT_READY) {
    197      throw new Error("This method should not be called when not ready");
    198    }
    199 
    200    if (this.#state === IPPProxyStates.ACTIVATING) {
    201      if (!this.#activatingPromise) {
    202        throw new Error("Activating without a promise?!?");
    203      }
    204 
    205      return this.#activatingPromise;
    206    }
    207 
    208    const activating = async () => {
    209      let started = false;
    210      try {
    211        started = await this.#startInternal();
    212      } catch (error) {
    213        this.#setErrorState(ERRORS.GENERIC, error);
    214        this.cancelChannelFilter();
    215        return;
    216      }
    217 
    218      if (this.#state === IPPProxyStates.ERROR) {
    219        return;
    220      }
    221 
    222      // Proxy failed to start but no error was given.
    223      if (!started) {
    224        this.#setState(IPPProxyStates.READY);
    225        return;
    226      }
    227 
    228      this.#setState(IPPProxyStates.ACTIVE);
    229 
    230      Glean.ipprotection.toggled.record({
    231        userAction,
    232        enabled: true,
    233      });
    234 
    235      if (userAction) {
    236        this.#reloadCurrentTab();
    237      }
    238    };
    239 
    240    this.#setState(IPPProxyStates.ACTIVATING);
    241    this.#activatingPromise = activating().finally(
    242      () => (this.#activatingPromise = null)
    243    );
    244    return this.#activatingPromise;
    245  }
    246 
    247  async #startInternal() {
    248    await lazy.IPProtectionServerlist.maybeFetchList();
    249 
    250    const enrollAndEntitleData =
    251      await lazy.IPPEnrollAndEntitleManager.maybeEnrollAndEntitle();
    252    if (!enrollAndEntitleData || !enrollAndEntitleData.isEnrolledAndEntitled) {
    253      this.#setErrorState(enrollAndEntitleData.error || ERRORS.GENERIC);
    254      return false;
    255    }
    256 
    257    if (lazy.IPProtectionService.state !== lazy.IPProtectionStates.READY) {
    258      this.#setErrorState(ERRORS.GENERIC);
    259      return false;
    260    }
    261 
    262    // Retry getting state if the previous attempt failed.
    263    if (this.#state === IPPProxyStates.ERROR) {
    264      this.updateState();
    265    }
    266 
    267    this.errors = [];
    268 
    269    this.createChannelFilter();
    270 
    271    // If the current proxy pass is valid, no need to re-authenticate.
    272    // Throws an error if the proxy pass is not available.
    273    if (this.#pass == null || this.#pass.shouldRotate()) {
    274      this.#pass = await this.#getProxyPass();
    275    }
    276    this.#schedulePassRotation(this.#pass);
    277 
    278    const location = lazy.IPProtectionServerlist.getDefaultLocation();
    279    const server = lazy.IPProtectionServerlist.selectServer(location?.city);
    280    if (!server) {
    281      this.#setErrorState(ERRORS.GENERIC, "No server found");
    282      return false;
    283    }
    284 
    285    lazy.logConsole.debug("Server:", server?.hostname);
    286 
    287    this.#connection.initialize(this.#pass.asBearerToken(), server);
    288 
    289    this.usageObserver.start();
    290    this.usageObserver.addIsolationKey(this.#connection.isolationKey);
    291 
    292    this.networkErrorObserver.start();
    293    this.networkErrorObserver.addIsolationKey(this.#connection.isolationKey);
    294 
    295    lazy.logConsole.info("Started");
    296 
    297    if (!!this.#connection?.active && !!this.#connection?.proxyInfo) {
    298      this.#activatedAt = ChromeUtils.now();
    299      return true;
    300    }
    301 
    302    return false;
    303  }
    304 
    305  /**
    306   * Stops the proxy.
    307   *
    308   * @param {boolean} userAction
    309   * True if started by user action, false if system action
    310   */
    311  async stop(userAction = true) {
    312    if (this.#state === IPPProxyStates.ACTIVATING) {
    313      if (!this.#activatingPromise) {
    314        throw new Error("Activating without a promise?!?");
    315      }
    316 
    317      await this.#activatingPromise.then(() => this.stop(userAction));
    318      return;
    319    }
    320 
    321    if (this.#state !== IPPProxyStates.ACTIVE) {
    322      return;
    323    }
    324 
    325    this.cancelChannelFilter();
    326 
    327    lazy.clearTimeout(this.#rotationTimer);
    328    this.#rotationTimer = 0;
    329 
    330    this.networkErrorObserver.stop();
    331 
    332    lazy.logConsole.info("Stopped");
    333 
    334    const sessionLength = ChromeUtils.now() - this.#activatedAt;
    335 
    336    Glean.ipprotection.toggled.record({
    337      userAction,
    338      duration: sessionLength,
    339      enabled: false,
    340    });
    341 
    342    this.#setState(IPPProxyStates.READY);
    343 
    344    if (userAction) {
    345      this.#reloadCurrentTab();
    346    }
    347  }
    348 
    349  /**
    350   * Gets the current window and reloads the selected tab.
    351   */
    352  #reloadCurrentTab() {
    353    let win = Services.wm.getMostRecentBrowserWindow();
    354    if (win) {
    355      win.gBrowser.reloadTab(win.gBrowser.selectedTab);
    356    }
    357  }
    358 
    359  /**
    360   * Stop any connections and reset the pass if the user has changed.
    361   */
    362  async reset() {
    363    this.#pass = null;
    364    if (
    365      this.#state === IPPProxyStates.ACTIVE ||
    366      this.#state === IPPProxyStates.ACTIVATING
    367    ) {
    368      await this.stop();
    369    }
    370  }
    371 
    372  #handleEvent(_event) {
    373    this.updateState();
    374  }
    375 
    376  /**
    377   * Fetches a new ProxyPass.
    378   * Throws an error on failures.
    379   *
    380   * @returns {Promise<ProxyPass|Error>} - the proxy pass if it available.
    381   */
    382  async #getProxyPass() {
    383    let { status, error, pass } =
    384      await lazy.IPProtectionService.guardian.fetchProxyPass();
    385    lazy.logConsole.debug("ProxyPass:", {
    386      status,
    387      valid: pass?.isValid(),
    388      error,
    389    });
    390 
    391    if (error || !pass || status != 200) {
    392      throw error || new Error(`Status: ${status}`);
    393    }
    394 
    395    return pass;
    396  }
    397 
    398  /**
    399   * Given a ProxyPass, sets a timer and triggers a rotation when it's about to expire.
    400   *
    401   * @param {*} pass
    402   */
    403  #schedulePassRotation(pass) {
    404    if (this.#rotationTimer) {
    405      lazy.clearTimeout(this.#rotationTimer);
    406      this.#rotationTimer = 0;
    407    }
    408 
    409    const now = Temporal.Now.instant();
    410    const rotationTimePoint = pass.rotationTimePoint;
    411    let msUntilRotation = now.until(rotationTimePoint).total("milliseconds");
    412    if (msUntilRotation <= 0) {
    413      msUntilRotation = 0;
    414    }
    415 
    416    lazy.logConsole.debug(
    417      `ProxyPass will rotate in ${now.until(rotationTimePoint).total("minutes")} minutes`
    418    );
    419    this.#rotationTimer = lazy.setTimeout(async () => {
    420      this.#rotationTimer = 0;
    421      if (!this.#connection?.active) {
    422        return;
    423      }
    424      lazy.logConsole.debug(`Statrting scheduled ProxyPass rotation`);
    425      await this.#rotateProxyPass();
    426    }, msUntilRotation);
    427  }
    428 
    429  /**
    430   * Starts a flow to get a new ProxyPass and replace the current one.
    431   *
    432   * @returns {Promise<void>} - Returns a promise that resolves when the rotation is complete or failed.
    433   * When it's called again while a rotation is in progress, it will return the existing promise.
    434   */
    435  async #rotateProxyPass() {
    436    if (this.#rotateProxyPassPromise) {
    437      return this.#rotateProxyPassPromise;
    438    }
    439    this.#rotateProxyPassPromise = this.#getProxyPass();
    440    const pass = await this.#rotateProxyPassPromise;
    441    this.#rotateProxyPassPromise = null;
    442    if (!pass) {
    443      return null;
    444    }
    445    // Inject the new token in the current connection
    446    if (this.#connection?.active) {
    447      this.#connection.replaceAuthToken(pass.asBearerToken());
    448      this.usageObserver.addIsolationKey(this.#connection.isolationKey);
    449      this.networkErrorObserver.addIsolationKey(this.#connection.isolationKey);
    450    }
    451    lazy.logConsole.debug("Successfully rotated token!");
    452    this.#pass = pass;
    453    this.#schedulePassRotation(pass);
    454    return null;
    455  }
    456 
    457  #handleProxyErrorEvent(event) {
    458    if (!this.#connection?.active) {
    459      return null;
    460    }
    461    const { isolationKey, level, httpStatus } = event.detail;
    462    if (isolationKey != this.#connection?.isolationKey) {
    463      // This error does not concern our current connection.
    464      // This could be due to an old request after a token refresh.
    465      return null;
    466    }
    467 
    468    if (httpStatus !== 401) {
    469      // Envoy returns a 401 if the token is rejected
    470      // So for now as we only care about rotating tokens we can exit here.
    471      return null;
    472    }
    473 
    474    if (level == "error" || this.#pass?.shouldRotate()) {
    475      // If this is a visible top-level error force a rotation
    476      return this.#rotateProxyPass();
    477    }
    478    return null;
    479  }
    480 
    481  updateState() {
    482    this.stop(false);
    483    this.reset();
    484 
    485    if (lazy.IPProtectionService.state === lazy.IPProtectionStates.READY) {
    486      this.#setState(IPPProxyStates.READY);
    487      return;
    488    }
    489 
    490    this.#setState(IPPProxyStates.NOT_READY);
    491  }
    492 
    493  /**
    494   * Helper to dispatch error messages.
    495   *
    496   * @param {string} error - the error message to send.
    497   * @param {string} [errorContext] - the error message to log.
    498   */
    499  #setErrorState(error, errorContext) {
    500    this.errors.push(error);
    501 
    502    if (this.errors.length > MAX_ERROR_HISTORY) {
    503      this.errors.splice(0, this.errors.length - MAX_ERROR_HISTORY);
    504    }
    505 
    506    this.#setState(IPPProxyStates.ERROR);
    507    lazy.logConsole.error(errorContext || error);
    508    Glean.ipprotection.error.record({ source: "ProxyManager" });
    509  }
    510 
    511  #setState(state) {
    512    if (state === this.#state) {
    513      return;
    514    }
    515 
    516    this.#state = state;
    517 
    518    this.dispatchEvent(
    519      new CustomEvent("IPPProxyManager:StateChanged", {
    520        bubbles: true,
    521        composed: true,
    522        detail: {
    523          state,
    524        },
    525      })
    526    );
    527  }
    528 }
    529 
    530 const IPPProxyManager = new IPPProxyManagerSingleton();
    531 
    532 export { IPPProxyManager };