Commit 1086febd authored by henry's avatar henry Committed by Pier Angelo Vendrame
Browse files

fixup! Bug 40597: Implement TorSettings module

Bug 41710: Remove StateCallback.
parent 8fee92cd
Loading
Loading
Loading
Loading
+1 −656
Original line number Diff line number Diff line
@@ -133,581 +133,6 @@ export const TorConnectTopics = Object.freeze({
  Error: "torconnect:error",
});

// The StateCallback is the base class to implement the various states.
// All states should extend it and implement a `run` function, which can
// optionally be async, and define an array of valid transitions.
// The parent class will handle everything else, including the transition to
// other states when the run function is complete etc...
// A system is also provided to allow this function to early-out. The runner
// should check the transitioning getter when appropriate and return.
// In addition to that, a state can implement a transitionRequested callback,
// which can be used in conjunction with a mechanism like Promise.race.
// This allows to handle, for example, users' requests to cancel a bootstrap
// attempt.
// A state can optionally define a cleanup function, that will be run in all
// cases before transitioning to the next state.
class StateCallback {
  #state;
  #promise;
  #transitioning = false;

  constructor(stateName) {
    this.#state = stateName;
  }

  async begin(...args) {
    lazy.logger.trace(`Entering ${this.#state} state`);
    // Make sure we always have an actual promise.
    try {
      this.#promise = Promise.resolve(this.run(...args));
    } catch (err) {
      this.#promise = Promise.reject(err);
    }
    try {
      // If the callback throws, transition to error as soon as possible.
      await this.#promise;
      lazy.logger.info(`${this.#state}'s run is done`);
    } catch (err) {
      if (this.transitioning) {
        lazy.logger.error(
          `A transition from ${
            this.#state
          } is already happening, silencing this exception.`,
          err
        );
        return;
      }
      lazy.logger.error(
        `${this.#state}'s run threw, transitioning to the Error state.`,
        err
      );
      this.changeState(TorConnectState.Error, err);
    }
  }

  async end(nextState) {
    lazy.logger.trace(
      `Ending state ${this.#state} (to transition to ${nextState})`
    );

    if (this.#transitioning) {
      // Should we check turn this into an error?
      // It will make dealing with the error state harder.
      lazy.logger.warn("this.#transitioning is already true.");
    }

    // Signal we should bail out ASAP.
    this.#transitioning = true;
    if (this.transitionRequested) {
      this.transitionRequested();
    }

    lazy.logger.debug(
      `Waiting for the ${
        this.#state
      }'s callback to return before the transition.`
    );
    try {
      await this.#promise;
    } finally {
      lazy.logger.debug(`Calling ${this.#state}'s cleanup, if implemented.`);
      if (this.cleanup) {
        try {
          await this.cleanup(nextState);
          lazy.logger.debug(`${this.#state}'s cleanup function done.`);
        } catch (e) {
          lazy.logger.warn(`${this.#state}'s cleanup function threw.`, e);
        }
      }
    }
  }

  changeState(stateName, ...args) {
    TorConnect._changeState(stateName, ...args);
  }

  get transitioning() {
    return this.#transitioning;
  }

  get state() {
    return this.#state;
  }
}

// async method to sleep for a given amount of time
const debugSleep = async ms => {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
};

class InitialState extends StateCallback {
  allowedTransitions = Object.freeze([
    TorConnectState.Disabled,
    TorConnectState.Bootstrapping,
    TorConnectState.Configuring,
    TorConnectState.Error,
  ]);

  constructor() {
    super(TorConnectState.Initial);
  }

  run() {
    // TODO: Block this transition until we successfully build a TorProvider.
  }
}

class ConfiguringState extends StateCallback {
  allowedTransitions = Object.freeze([
    TorConnectState.AutoBootstrapping,
    TorConnectState.Bootstrapping,
    TorConnectState.Error,
  ]);

  constructor() {
    super(TorConnectState.Configuring);
  }

  run() {
    TorConnect._bootstrapProgress = 0;
  }
}

class BootstrappingState extends StateCallback {
  #bootstrap = null;
  #bootstrapError = null;
  #internetTest = null;
  #cancelled = false;

  allowedTransitions = Object.freeze([
    TorConnectState.Configuring,
    TorConnectState.Bootstrapped,
    TorConnectState.Error,
  ]);

  constructor() {
    super(TorConnectState.Bootstrapping);
  }

  async run() {
    if (await this.#simulateCensorship()) {
      return;
    }

    this.#bootstrap = new lazy.TorBootstrapRequest();
    this.#bootstrap.onbootstrapstatus = (progress, status) => {
      TorConnect._updateBootstrapProgress(progress, status);
    };
    this.#bootstrap.onbootstrapcomplete = () => {
      this.#internetTest.cancel();
      this.changeState(TorConnectState.Bootstrapped);
    };
    this.#bootstrap.onbootstraperror = error => {
      if (this.#cancelled) {
        // We ignore this error since it occurred after cancelling (by the
        // user). We assume the error is just a side effect of the cancelling.
        // E.g. If the cancelling is triggered late in the process, we get
        // "Building circuits: Establishing a Tor circuit failed".
        // TODO: Maybe move this logic deeper in the process to know when to
        // filter out such errors triggered by cancelling.
        lazy.logger.warn("Post-cancel error.", error);
        return;
      }
      // We have to wait for the Internet test to finish before sending the
      // bootstrap error
      this.#bootstrapError = error;
      this.#maybeTransitionToError();
    };

    this.#internetTest = new InternetTest();
    this.#internetTest.onResult = status => {
      TorConnect._internetStatus = status;
      this.#maybeTransitionToError();
    };
    this.#internetTest.onError = () => {
      this.#maybeTransitionToError();
    };

    this.#bootstrap.bootstrap();
  }

  async cleanup(nextState) {
    if (nextState === TorConnectState.Configuring) {
      // stop bootstrap process if user cancelled
      this.#cancelled = true;
      this.#internetTest?.cancel();
      await this.#bootstrap?.cancel();
    }
  }

  #maybeTransitionToError() {
    if (
      this.#internetTest.status === InternetStatus.Unknown &&
      this.#internetTest.error === null &&
      this.#internetTest.enabled
    ) {
      // We have been called by a failed bootstrap, but the internet test has
      // not run yet - force it to run immediately!
      this.#internetTest.test();
      // Return from this call, because the Internet test's callback will call
      // us again.
      return;
    }
    // Do not transition to the offline error until we are sure that also the
    // bootstrap failed, in case Moat is down but the bootstrap can proceed
    // anyway.
    if (!this.#bootstrapError) {
      return;
    }
    if (this.#internetTest.status === InternetStatus.Offline) {
      this.changeState(
        TorConnectState.Error,
        new TorConnectError(TorConnectError.Offline)
      );
    } else {
      // Give priority to the bootstrap error, in case the Internet test fails
      TorConnect._hasBootstrapEverFailed = true;
      this.changeState(
        TorConnectState.Error,
        new TorConnectError(
          TorConnectError.BootstrapError,
          this.#bootstrapError
        )
      );
    }
  }

  async #simulateCensorship() {
    // debug hook to simulate censorship preventing bootstrapping
    const censorshipLevel = Services.prefs.getIntPref(
      TorConnectPrefs.censorship_level,
      0
    );
    if (censorshipLevel <= 0) {
      return false;
    }

    await debugSleep(1500);
    if (this.transitioning) {
      // Already left this state.
      return true;
    }
    TorConnect._hasBootstrapEverFailed = true;
    if (censorshipLevel === 2) {
      const codes = Object.keys(TorConnect._countryNames);
      TorConnect._detectedLocation =
        codes[Math.floor(Math.random() * codes.length)];
    }
    const err = new Error("Censorship simulation");
    err.phase = "conn";
    err.reason = "noroute";
    this.changeState(
      TorConnectState.Error,
      new TorConnectError(TorConnectError.BootstrapError, err)
    );
    return true;
  }
}

class AutoBootstrappingState extends StateCallback {
  #moat;
  #settings;
  #changedSettings = false;
  #transitionPromise;
  #transitionResolve;

  allowedTransitions = Object.freeze([
    TorConnectState.Configuring,
    TorConnectState.Bootstrapped,
    TorConnectState.Error,
  ]);

  constructor() {
    super(TorConnectState.AutoBootstrapping);
    this.#transitionPromise = new Promise(resolve => {
      this.#transitionResolve = resolve;
    });
  }

  async run(countryCode) {
    if (await this.#simulateCensorship(countryCode)) {
      return;
    }
    await this.#initMoat();
    if (this.transitioning) {
      return;
    }
    await this.#fetchSettings(countryCode);
    if (this.transitioning) {
      return;
    }
    await this.#trySettings();
  }

  /**
   * Simulate a censorship event, if needed.
   *
   * @param {string} countryCode The country code passed to the state
   * @returns {Promise<boolean>} true if we are simulating the censorship and
   * the bootstrap should stop immediately, or false if the bootstrap should
   * continue normally.
   */
  async #simulateCensorship(countryCode) {
    const censorshipLevel = Services.prefs.getIntPref(
      TorConnectPrefs.censorship_level,
      0
    );
    if (censorshipLevel <= 0) {
      return false;
    }

    // Very severe censorship: always fail even after manually selecting
    // location specific settings.
    if (censorshipLevel === 3) {
      await debugSleep(2500);
      if (!this.transitioning) {
        this.changeState(
          TorConnectState.Error,
          new TorConnectError(TorConnectError.AllSettingsFailed)
        );
      }
      return true;
    }

    // Severe censorship: only fail after auto selecting, but succeed after
    // manually selecting a country.
    if (censorshipLevel === 2 && !countryCode) {
      await debugSleep(2500);
      if (!this.transitioning) {
        this.changeState(
          TorConnectState.Error,
          new TorConnectError(TorConnectError.CannotDetermineCountry)
        );
      }
      return true;
    }

    return false;
  }

  /**
   * Initialize the MoatRPC to communicate with the backend.
   */
  async #initMoat() {
    this.#moat = new lazy.MoatRPC();
    // We need to wait Moat's initialization even when we are requested to
    // transition to another state to be sure its uninit will have its intended
    // effect. So, do not use Promise.race here.
    await this.#moat.init();
  }

  /**
   * Lookup user's potential censorship circumvention settings from Moat
   * service.
   */
  async #fetchSettings(countryCode) {
    // For now, throw any errors we receive from the backend, except when it was
    // unable to detect user's country/region.
    // If we use specialized error objects, we could pass the original errors to
    // them.
    const maybeSettings = await Promise.race([
      this.#moat.circumvention_settings(
        [...lazy.TorSettings.builtinBridgeTypes, "vanilla"],
        countryCode
      ),
      // This might set maybeSettings to undefined.
      this.#transitionPromise,
    ]);
    if (maybeSettings?.country) {
      TorConnect._detectedLocation = maybeSettings.country;
    }

    if (maybeSettings?.settings && maybeSettings.settings.length) {
      this.#settings = maybeSettings.settings;
    } else if (!this.transitioning) {
      // Keep consistency with the other call.
      this.#settings = await Promise.race([
        this.#moat.circumvention_defaults([
          ...lazy.TorSettings.builtinBridgeTypes,
          "vanilla",
        ]),
        // This might set this.#settings to undefined.
        this.#transitionPromise,
      ]);
    }

    if (!this.#settings?.length && !this.transitioning) {
      if (!TorConnect._detectedLocation) {
        // unable to determine country
        throw new TorConnectError(TorConnectError.CannotDetermineCountry);
      } else {
        // no settings available for country
        throw new TorConnectError(TorConnectError.NoSettingsForCountry);
      }
    }
  }

  /**
   * Try to apply the settings we fetched.
   */
  async #trySettings() {
    // Otherwise, apply each of our settings and try to bootstrap with each.
    for (const [index, currentSetting] of this.#settings.entries()) {
      if (this.transitioning) {
        break;
      }

      lazy.logger.info(
        `Attempting Bootstrap with configuration ${index + 1}/${
          this.#settings.length
        }`
      );

      // Send the new settings directly to the provider. We will save them only
      // if the bootstrap succeeds.
      // FIXME: We should somehow signal TorSettings users that we have set
      // custom settings, and they should not apply theirs until we are done
      // with trying ours.
      // Otherwise, the new settings provided by the user while we were
      // bootstrapping could be the ones that cause the bootstrap to succeed,
      // but we overwrite them (unless we backup the original settings, and then
      // save our new settings only if they have not changed).
      // Another idea (maybe easier to implement) is to disable the settings
      // UI while *any* bootstrap is going on.
      // This is also documented in tor-browser#41921.
      const provider = await lazy.TorProviderBuilder.build();
      this.#changedSettings = true;
      // We need to merge with old settings, in case the user is using a proxy
      // or is behind a firewall.
      await provider.writeSettings({
        ...lazy.TorSettings.getSettings(),
        ...currentSetting,
      });

      // Build out our bootstrap request.
      const bootstrap = new lazy.TorBootstrapRequest();
      bootstrap.onbootstrapstatus = (progress, status) => {
        TorConnect._updateBootstrapProgress(progress, status);
      };
      bootstrap.onbootstraperror = error => {
        lazy.logger.error("Auto-Bootstrap error", error);
      };

      // Begin the bootstrap.
      const success = await Promise.race([
        bootstrap.bootstrap(),
        this.#transitionPromise,
      ]);
      // Either the bootstrap request has finished, or a transition (caused by
      // an error or by user's cancelation) started.
      // However, we cannot be already transitioning in case of success, so if
      // we are we should cancel the current bootstrap.
      // With the current TorProvider, this will set DisableNetwork=1 again,
      // which is what the user wanted if they canceled.
      if (this.transitioning) {
        if (success) {
          lazy.logger.warn(
            "We were already transitioning after a success, we were not expecting this."
          );
        }
        bootstrap.cancel();
        return;
      }
      if (success) {
        // Persist the current settings to preferences.
        lazy.TorSettings.setSettings(currentSetting);
        lazy.TorSettings.saveToPrefs();
        // Do not await `applySettings`. Otherwise this opens up a window of
        // time where the user can still "Cancel" the bootstrap.
        // We are calling `applySettings` just to be on the safe side, but the
        // settings we are passing now should be exactly the same we already
        // passed earlier.
        lazy.TorSettings.applySettings().catch(e =>
          lazy.logger.error("TorSettings.applySettings threw unexpectedly.", e)
        );
        this.changeState(TorConnectState.Bootstrapped);
        return;
      }
    }

    // Only explicitly change state here if something else has not transitioned
    // us.
    if (!this.transitioning) {
      throw new TorConnectError(TorConnectError.AllSettingsFailed);
    }
  }

  transitionRequested() {
    this.#transitionResolve();
  }

  async cleanup(nextState) {
    // No need to await.
    this.#moat?.uninit();
    this.#moat = null;

    if (this.#changedSettings && nextState !== TorConnectState.Bootstrapped) {
      try {
        await lazy.TorSettings.applySettings();
      } catch (e) {
        // We cannot do much if the original settings were bad or
        // if the connection closed, so just report it in the
        // console.
        lazy.logger.warn("Failed to restore original settings.", e);
      }
    }
  }
}

class BootstrappedState extends StateCallback {
  // We may need to leave the bootstrapped state if the tor daemon
  // exits (if it is restarted, we will have to bootstrap again).
  allowedTransitions = Object.freeze([TorConnectState.Configuring]);

  constructor() {
    super(TorConnectState.Bootstrapped);
  }

  run() {
    // Notify observers of bootstrap completion.
    Services.obs.notifyObservers(null, TorConnectTopics.BootstrapComplete);
  }
}

class ErrorState extends StateCallback {
  allowedTransitions = Object.freeze([TorConnectState.Configuring]);

  static #hasEverHappened = false;

  constructor() {
    super(TorConnectState.Error);
    ErrorState.#hasEverHappened = true;
  }

  run(_error) {
    this.changeState(TorConnectState.Configuring);
  }

  static get hasEverHappened() {
    return ErrorState.#hasEverHappened;
  }
}

class DisabledState extends StateCallback {
  // Trap state: no way to leave the Disabled state.
  allowedTransitions = Object.freeze([]);

  constructor() {
    super(TorConnectState.Disabled);
  }

  async run() {
    lazy.logger.debug("Entered the disabled state.");
  }
}

/**
 * @callback ProgressCallback
 *
@@ -1433,7 +858,6 @@ class InternetTest {
}

export const TorConnect = {
  _stateHandler: new InitialState(),
  _bootstrapProgress: 0,
  _internetStatus: InternetStatus.Unknown,
  // list of country codes Moat has settings for
@@ -1454,91 +878,13 @@ export const TorConnect = {
  _errorDetails: null,
  _logHasWarningOrError: false,
  _hasBootstrapEverFailed: false,
  _transitionPromise: null,

  // This is used as a helper to make the state of about:torconnect persistent
  // during a session, but TorConnect does not use this data at all.
  _uiState: {},

  _stateCallbacks: Object.freeze(
    new Map([
      // Initial is never transitioned to
      [TorConnectState.Initial, InitialState],
      [TorConnectState.Configuring, ConfiguringState],
      [TorConnectState.Bootstrapping, BootstrappingState],
      [TorConnectState.AutoBootstrapping, AutoBootstrappingState],
      [TorConnectState.Bootstrapped, BootstrappedState],
      [TorConnectState.Error, ErrorState],
      [TorConnectState.Disabled, DisabledState],
    ])
  ),

  _makeState(state) {
    const klass = this._stateCallbacks.get(state);
    if (!klass) {
      throw new Error(`${state} is not a valid state.`);
    }
    return new klass();
  },

  async _changeState(newState, ...args) {
    if (this._stateHandler.transitioning) {
      // Avoid an exception to prevent it to be propagated to the original
      // begin call.
      lazy.logger.warn("Already transitioning");
      return;
    }
    const prevState = this._stateHandler;

    // ensure this is a valid state transition
    if (!prevState.allowedTransitions.includes(newState)) {
      throw Error(
        `TorConnect: Attempted invalid state transition from ${prevState.state} to ${newState}`
      );
    }

    lazy.logger.trace(
      `Try transitioning from ${prevState.state} to ${newState}`,
      args
    );
    try {
      await prevState.end(newState);
    } catch (e) {
      // We take for granted that the begin of this state will call us again,
      // to request the transition to the error state.
      if (newState !== TorConnectState.Error) {
        lazy.logger.debug(
          `Refusing the transition from ${prevState.state} to ${newState} because the previous state threw.`
        );
        return;
      }
    }

    // Set our new state first so that state transitions can themselves
    // trigger a state transition.
    this._stateHandler = this._makeState(newState);

    // Error signal needs to be sent out before we enter the Error state.
    // Expected on android `onBootstrapError` to set lastKnownError.
    // Expected in about:torconnect to set the error codes and internet status
    // *before* the StateChange signal.
    if (newState === TorConnectState.Error) {
      let error = args[0];
      if (!(error instanceof TorConnectError)) {
        error = new TorConnectError(TorConnectError.ExternalError, error);
      }
      TorConnect._errorCode = error.code;
      TorConnect._errorDetails = error;
      lazy.logger.error(`Entering error state (${error.code})`, error);

      Services.obs.notifyObservers(error, TorConnectTopics.Error);
    }

    Services.obs.notifyObservers(
      { state: newState },
      TorConnectTopics.StateChange
    );
    this._stateHandler.begin(...args);
    // TODO: Remove.
  },

  _updateBootstrapProgress(progress, status) {
@@ -1559,7 +905,6 @@ export const TorConnect = {
  // init should be called by TorStartupService
  init() {
    lazy.logger.debug("TorConnect.init()");
    this._stateHandler.begin();

    if (!this.enabled) {
      // Disabled