summaryrefslogtreecommitdiffstats
path: root/browser/components/doh/DoHController.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/doh/DoHController.jsm')
-rw-r--r--browser/components/doh/DoHController.jsm615
1 files changed, 615 insertions, 0 deletions
diff --git a/browser/components/doh/DoHController.jsm b/browser/components/doh/DoHController.jsm
new file mode 100644
index 0000000000..e11578bd58
--- /dev/null
+++ b/browser/components/doh/DoHController.jsm
@@ -0,0 +1,615 @@
+/* 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";
+
+/*
+ * This module runs the automated heuristics to enable/disable DoH on different
+ * networks. Heuristics are run at startup and upon network changes.
+ * Heuristics are disabled if the user sets their DoH provider or mode manually.
+ */
+var EXPORTED_SYMBOLS = ["DoHController"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+ ClientID: "resource://gre/modules/ClientID.jsm",
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.jsm",
+ Config: "resource:///modules/DoHConfig.jsm",
+ Heuristics: "resource:///modules/DoHHeuristics.jsm",
+ Preferences: "resource://gre/modules/Preferences.jsm",
+ setTimeout: "resource://gre/modules/Timer.jsm",
+ clearTimeout: "resource://gre/modules/Timer.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "kDebounceTimeout",
+ "doh-rollout.network-debounce-timeout",
+ 1000
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gCaptivePortalService",
+ "@mozilla.org/network/captive-portal-service;1",
+ "nsICaptivePortalService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gDNSService",
+ "@mozilla.org/network/dns-service;1",
+ "nsIDNSService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gNetworkLinkService",
+ "@mozilla.org/network/network-link-service;1",
+ "nsINetworkLinkService"
+);
+
+// Stores whether we've done first-run.
+const FIRST_RUN_PREF = "doh-rollout.doneFirstRun";
+
+// Records if the user opted in/out of DoH study by clicking on doorhanger
+const DOORHANGER_USER_DECISION_PREF = "doh-rollout.doorhanger-decision";
+
+// Set when we detect that the user set their DoH provider or mode manually.
+// If set, we don't run heuristics.
+const DISABLED_PREF = "doh-rollout.disable-heuristics";
+
+// Set when we detect either a non-DoH enterprise policy, or a DoH policy that
+// tells us to disable it. This pref's effect is to suppress the opt-out CFR.
+const SKIP_HEURISTICS_PREF = "doh-rollout.skipHeuristicsCheck";
+
+// Whether to clear doh-rollout.mode on shutdown. When false, the mode value
+// that exists at shutdown will be used at startup until heuristics re-run.
+const CLEAR_ON_SHUTDOWN_PREF = "doh-rollout.clearModeOnShutdown";
+
+const BREADCRUMB_PREF = "doh-rollout.self-enabled";
+
+// Necko TRR prefs to watch for user-set values.
+const NETWORK_TRR_MODE_PREF = "network.trr.mode";
+const NETWORK_TRR_URI_PREF = "network.trr.uri";
+
+const TRR_LIST_PREF = "network.trr.resolvers";
+
+const ROLLOUT_MODE_PREF = "doh-rollout.mode";
+const ROLLOUT_URI_PREF = "doh-rollout.uri";
+
+const TRR_SELECT_DRY_RUN_RESULT_PREF =
+ "doh-rollout.trr-selection.dry-run-result";
+
+const HEURISTICS_TELEMETRY_CATEGORY = "doh";
+const TRRSELECT_TELEMETRY_CATEGORY = "security.doh.trrPerformance";
+
+const kLinkStatusChangedTopic = "network:link-status-changed";
+const kConnectivityTopic = "network:captive-portal-connectivity";
+const kPrefChangedTopic = "nsPref:changed";
+
+// Helper function to hash the network ID concatenated with telemetry client ID.
+// This prevents us from being able to tell if 2 clients are on the same network.
+function getHashedNetworkID() {
+ let currentNetworkID = gNetworkLinkService.networkID;
+ if (!currentNetworkID) {
+ return "";
+ }
+
+ let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+
+ hasher.init(Ci.nsICryptoHash.SHA256);
+ // Concat the client ID with the network ID before hashing.
+ let clientNetworkID = ClientID.getClientID() + currentNetworkID;
+ hasher.update(
+ clientNetworkID.split("").map(c => c.charCodeAt(0)),
+ clientNetworkID.length
+ );
+ return hasher.finish(true);
+}
+
+const DoHController = {
+ _heuristicsAreEnabled: false,
+
+ async init() {
+ await this.migrateLocalStoragePrefs();
+ await this.migrateOldTrrMode();
+ await this.migrateNextDNSEndpoint();
+
+ Services.telemetry.setEventRecordingEnabled(
+ HEURISTICS_TELEMETRY_CATEGORY,
+ true
+ );
+ Services.telemetry.setEventRecordingEnabled(
+ TRRSELECT_TELEMETRY_CATEGORY,
+ true
+ );
+
+ Services.obs.addObserver(this, Config.kConfigUpdateTopic);
+ Preferences.observe(NETWORK_TRR_MODE_PREF, this);
+ Preferences.observe(NETWORK_TRR_URI_PREF, this);
+
+ if (Config.enabled) {
+ await this.maybeEnableHeuristics();
+ } else if (Preferences.get(FIRST_RUN_PREF, false)) {
+ await this.rollback();
+ }
+
+ this._asyncShutdownBlocker = async () => {
+ await this.disableHeuristics("shutdown");
+ };
+
+ AsyncShutdown.profileBeforeChange.addBlocker(
+ "DoHController: clear state and remove observers",
+ this._asyncShutdownBlocker
+ );
+
+ Preferences.set(FIRST_RUN_PREF, true);
+ },
+
+ // Also used by tests to reset DoHController state (prefs are not cleared
+ // here - tests do that when needed between _uninit and init).
+ async _uninit() {
+ Services.obs.removeObserver(this, Config.kConfigUpdateTopic);
+ Preferences.ignore(NETWORK_TRR_MODE_PREF, this);
+ Preferences.ignore(NETWORK_TRR_URI_PREF, this);
+ AsyncShutdown.profileBeforeChange.removeBlocker(this._asyncShutdownBlocker);
+ await this.disableHeuristics("shutdown");
+ },
+
+ // Called to reset state when a new config is available.
+ async reset() {
+ await this._uninit();
+ await this.init();
+ },
+
+ async migrateLocalStoragePrefs() {
+ const BALROG_MIGRATION_COMPLETED_PREF = "doh-rollout.balrog-migration-done";
+ const ADDON_ID = "doh-rollout@mozilla.org";
+
+ // Migrate updated local storage item names. If this has already been done once, skip the migration
+ const isMigrated = Preferences.get(BALROG_MIGRATION_COMPLETED_PREF, false);
+
+ if (isMigrated) {
+ return;
+ }
+
+ let policy = WebExtensionPolicy.getByID(ADDON_ID);
+ if (!policy) {
+ return;
+ }
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ policy.extension
+ );
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+
+ // Previously, the DoH heuristics were bundled as an add-on. Early versions
+ // of this add-on used local storage instead of prefs to persist state. This
+ // function migrates the values that are still relevant to their new pref
+ // counterparts.
+ const legacyLocalStorageKeys = [
+ "doneFirstRun",
+ DOORHANGER_USER_DECISION_PREF,
+ DISABLED_PREF,
+ ];
+
+ for (let item of legacyLocalStorageKeys) {
+ let data = await idbConn.get(item);
+ let value = data[item];
+
+ if (data.hasOwnProperty(item)) {
+ let migratedName = item;
+
+ if (!item.startsWith("doh-rollout.")) {
+ migratedName = "doh-rollout." + item;
+ }
+
+ Preferences.set(migratedName, value);
+ }
+ }
+
+ await idbConn.clear();
+ await idbConn.close();
+
+ // Set pref to skip this function in the future.
+ Preferences.set(BALROG_MIGRATION_COMPLETED_PREF, true);
+ },
+
+ // Previous versions of the DoH frontend worked by setting network.trr.mode
+ // directly to turn DoH on/off. This makes sure we clear that value and also
+ // the pref we formerly used to track changes to it.
+ async migrateOldTrrMode() {
+ const PREVIOUS_TRR_MODE_PREF = "doh-rollout.previous.trr.mode";
+
+ if (Preferences.get(PREVIOUS_TRR_MODE_PREF) === undefined) {
+ return;
+ }
+
+ Preferences.reset(NETWORK_TRR_MODE_PREF);
+ Preferences.reset(PREVIOUS_TRR_MODE_PREF);
+ },
+
+ async migrateNextDNSEndpoint() {
+ // NextDNS endpoint changed from trr.dns.nextdns.io to firefox.dns.nextdns.io
+ // This migration updates any pref values that might be using the old value
+ // to the new one. We support values that match the exact URL that shipped
+ // in the network.trr.resolvers pref in prior versions of the browser.
+ // The migration is a direct static replacement of the string.
+ const oldURL = "https://trr.dns.nextdns.io/";
+ const newURL = "https://firefox.dns.nextdns.io/";
+ const prefsToMigrate = [
+ "network.trr.resolvers",
+ "network.trr.uri",
+ "network.trr.custom_uri",
+ "doh-rollout.trr-selection.dry-run-result",
+ "doh-rollout.uri",
+ ];
+
+ for (let pref of prefsToMigrate) {
+ if (!Preferences.isSet(pref)) {
+ continue;
+ }
+ Preferences.set(pref, Preferences.get(pref).replaceAll(oldURL, newURL));
+ }
+ },
+
+ // The "maybe" is because there are two cases when we don't enable heuristics:
+ // 1. If we detect that TRR mode or URI have user values, or we previously
+ // detected this (i.e. DISABLED_PREF is true)
+ // 2. If there are any non-DoH enterprise policies active
+ async maybeEnableHeuristics() {
+ if (Preferences.get(DISABLED_PREF)) {
+ return;
+ }
+
+ let policyResult = await Heuristics.checkEnterprisePolicy();
+
+ if (["policy_without_doh", "disable_doh"].includes(policyResult)) {
+ await this.setState("policyDisabled");
+ Preferences.set(SKIP_HEURISTICS_PREF, true);
+ return;
+ }
+
+ Preferences.reset(SKIP_HEURISTICS_PREF);
+
+ if (
+ Preferences.isSet(NETWORK_TRR_MODE_PREF) ||
+ Preferences.isSet(NETWORK_TRR_URI_PREF)
+ ) {
+ await this.setState("manuallyDisabled");
+ Preferences.set(DISABLED_PREF, true);
+ return;
+ }
+
+ await this.runTRRSelection();
+ await this.runHeuristics("startup");
+ Services.obs.addObserver(this, kLinkStatusChangedTopic);
+ Services.obs.addObserver(this, kConnectivityTopic);
+
+ this._heuristicsAreEnabled = true;
+ },
+
+ _lastHeuristicsRunTimestamp: 0,
+ async runHeuristics(evaluateReason) {
+ let start = Date.now();
+ // If this function is called in quick succession, _lastHeuristicsRunTimestamp
+ // might be refreshed while we are still awaiting Heuristics.run() below.
+ this._lastHeuristicsRunTimestamp = start;
+
+ let results = await Heuristics.run();
+
+ if (
+ !gNetworkLinkService.isLinkUp ||
+ this._lastDebounceTimestamp > start ||
+ this._lastHeuristicsRunTimestamp > start ||
+ gCaptivePortalService.state == gCaptivePortalService.LOCKED_PORTAL
+ ) {
+ // If the network is currently down or there was a debounce triggered
+ // while we were running heuristics, it means the network fluctuated
+ // during this heuristics run. We simply discard the results in this case.
+ // Same thing if there was another heuristics run triggered or if we have
+ // detected a locked captive portal while this one was ongoing.
+ return;
+ }
+
+ let decision = Object.values(results).includes(Heuristics.DISABLE_DOH)
+ ? Heuristics.DISABLE_DOH
+ : Heuristics.ENABLE_DOH;
+
+ let getCaptiveStateString = () => {
+ switch (gCaptivePortalService.state) {
+ case gCaptivePortalService.NOT_CAPTIVE:
+ return "not_captive";
+ case gCaptivePortalService.UNLOCKED_PORTAL:
+ return "unlocked";
+ case gCaptivePortalService.LOCKED_PORTAL:
+ return "locked";
+ default:
+ return "unknown";
+ }
+ };
+
+ let resultsForTelemetry = {
+ evaluateReason,
+ steeredProvider: "",
+ captiveState: getCaptiveStateString(),
+ // NOTE: This might not yet be available after a network change. We mainly
+ // care about the startup case though - we want to look at whether the
+ // heuristics result is consistent for networkIDs often seen at startup.
+ // TODO: Use this data to implement cached results to use early at startup.
+ networkID: getHashedNetworkID(),
+ };
+
+ if (results.steeredProvider) {
+ gDNSService.setDetectedTrrURI(results.steeredProvider.uri);
+ resultsForTelemetry.steeredProvider = results.steeredProvider.name;
+ }
+
+ if (decision === Heuristics.DISABLE_DOH) {
+ await this.setState("disabled");
+ } else {
+ await this.setState("enabled");
+ }
+
+ // For telemetry, we group the heuristics results into three categories.
+ // Only heuristics with a DISABLE_DOH result are included.
+ // Each category is finally included in the event as a comma-separated list.
+ let canaries = [];
+ let filtering = [];
+ let enterprise = [];
+ let platform = [];
+
+ for (let [heuristicName, result] of Object.entries(results)) {
+ if (result !== Heuristics.DISABLE_DOH) {
+ continue;
+ }
+
+ if (["canary", "zscalerCanary"].includes(heuristicName)) {
+ canaries.push(heuristicName);
+ } else if (
+ ["browserParent", "google", "youtube"].includes(heuristicName)
+ ) {
+ filtering.push(heuristicName);
+ } else if (
+ ["policy", "modifiedRoots", "thirdPartyRoots"].includes(heuristicName)
+ ) {
+ enterprise.push(heuristicName);
+ } else if (["vpn", "proxy", "nrpt"].includes(heuristicName)) {
+ platform.push(heuristicName);
+ }
+ }
+
+ resultsForTelemetry.canaries = canaries.join(",");
+ resultsForTelemetry.filtering = filtering.join(",");
+ resultsForTelemetry.enterprise = enterprise.join(",");
+ resultsForTelemetry.platform = platform.join(",");
+
+ Services.telemetry.recordEvent(
+ HEURISTICS_TELEMETRY_CATEGORY,
+ "evaluate_v2",
+ "heuristics",
+ decision,
+ resultsForTelemetry
+ );
+ },
+
+ async setState(state) {
+ switch (state) {
+ case "disabled":
+ Preferences.set(ROLLOUT_MODE_PREF, 0);
+ break;
+ case "UIOk":
+ Preferences.set(BREADCRUMB_PREF, true);
+ break;
+ case "enabled":
+ Preferences.set(ROLLOUT_MODE_PREF, 2);
+ Preferences.set(BREADCRUMB_PREF, true);
+ break;
+ case "policyDisabled":
+ case "manuallyDisabled":
+ case "UIDisabled":
+ Preferences.reset(BREADCRUMB_PREF);
+ // Fall through.
+ case "rollback":
+ Preferences.reset(ROLLOUT_MODE_PREF);
+ break;
+ case "shutdown":
+ if (Preferences.get(CLEAR_ON_SHUTDOWN_PREF, true)) {
+ Preferences.reset(ROLLOUT_MODE_PREF);
+ }
+ break;
+ }
+
+ Services.telemetry.recordEvent(
+ HEURISTICS_TELEMETRY_CATEGORY,
+ "state",
+ state,
+ "null"
+ );
+ },
+
+ async disableHeuristics(state) {
+ await this.setState(state);
+
+ if (!this._heuristicsAreEnabled) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, kLinkStatusChangedTopic);
+ Services.obs.removeObserver(this, kConnectivityTopic);
+ this._heuristicsAreEnabled = false;
+ },
+
+ async rollback() {
+ await this.disableHeuristics("rollback");
+ },
+
+ async runTRRSelection() {
+ // If persisting the selection is disabled, clear the existing
+ // selection.
+ if (!Config.trrSelection.commitResult) {
+ Preferences.reset(ROLLOUT_URI_PREF);
+ }
+
+ if (!Config.trrSelection.enabled) {
+ return;
+ }
+
+ if (Preferences.isSet(ROLLOUT_URI_PREF)) {
+ return;
+ }
+
+ await this.runTRRSelectionDryRun();
+
+ // If persisting the selection is disabled, don't commit the value.
+ if (!Config.trrSelection.commitResult) {
+ return;
+ }
+
+ Preferences.set(
+ ROLLOUT_URI_PREF,
+ Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF)
+ );
+ },
+
+ async runTRRSelectionDryRun() {
+ if (Preferences.isSet(TRR_SELECT_DRY_RUN_RESULT_PREF)) {
+ // Check whether the existing dry-run-result is in the default
+ // list of TRRs. If it is, all good. Else, run the dry run again.
+ let dryRunResult = Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF);
+ let defaultTRRs = JSON.parse(
+ Services.prefs.getDefaultBranch("").getCharPref(TRR_LIST_PREF)
+ );
+ let dryRunResultIsValid = defaultTRRs.some(
+ trr => trr.url == dryRunResult
+ );
+ if (dryRunResultIsValid) {
+ return;
+ }
+ }
+
+ let setDryRunResultAndRecordTelemetry = trr => {
+ Preferences.set(TRR_SELECT_DRY_RUN_RESULT_PREF, trr);
+ Services.telemetry.recordEvent(
+ TRRSELECT_TELEMETRY_CATEGORY,
+ "trrselect",
+ "dryrunresult",
+ trr.substring(0, 40) // Telemetry payload max length
+ );
+ };
+
+ if (Cu.isInAutomation) {
+ // For mochitests, just record telemetry with a dummy result.
+ // TRRPerformance.jsm is tested in xpcshell.
+ setDryRunResultAndRecordTelemetry("https://dummytrr.com/query");
+ return;
+ }
+
+ // Importing the module here saves us from having to do it at startup, and
+ // ensures tests have time to set prefs before the module initializes.
+ let { TRRRacer } = ChromeUtils.import(
+ "resource:///modules/TRRPerformance.jsm"
+ );
+ await new Promise(resolve => {
+ let racer = new TRRRacer(() => {
+ setDryRunResultAndRecordTelemetry(racer.getFastestTRR(true));
+ resolve();
+ });
+ racer.run();
+ });
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case kLinkStatusChangedTopic:
+ this.onConnectionChanged();
+ break;
+ case kConnectivityTopic:
+ this.onConnectivityAvailable();
+ break;
+ case kPrefChangedTopic:
+ this.onPrefChanged(data);
+ break;
+ case Config.kConfigUpdateTopic:
+ this.reset();
+ break;
+ }
+ },
+
+ async onPrefChanged(pref) {
+ switch (pref) {
+ case NETWORK_TRR_URI_PREF:
+ case NETWORK_TRR_MODE_PREF:
+ Preferences.set(DISABLED_PREF, true);
+ await this.disableHeuristics("manuallyDisabled");
+ break;
+ }
+ },
+
+ // Connection change events are debounced to allow the network to settle.
+ // We wait for the network to be up for a period of kDebounceTimeout before
+ // handling the change. The timer is canceled when the network goes down and
+ // restarted the first time we learn that it went back up.
+ _debounceTimer: null,
+ _cancelDebounce() {
+ if (!this._debounceTimer) {
+ return;
+ }
+
+ clearTimeout(this._debounceTimer);
+ this._debounceTimer = null;
+ },
+
+ _lastDebounceTimestamp: 0,
+ onConnectionChanged() {
+ if (!gNetworkLinkService.isLinkUp) {
+ // Network is down - reset debounce timer.
+ this._cancelDebounce();
+ return;
+ }
+
+ if (this._debounceTimer) {
+ // Already debouncing - nothing to do.
+ return;
+ }
+
+ this._lastDebounceTimestamp = Date.now();
+ this._debounceTimer = setTimeout(() => {
+ this._cancelDebounce();
+ this.onConnectionChangedDebounced();
+ }, kDebounceTimeout);
+ },
+
+ async onConnectionChangedDebounced() {
+ if (!gNetworkLinkService.isLinkUp) {
+ return;
+ }
+
+ if (gCaptivePortalService.state == gCaptivePortalService.LOCKED_PORTAL) {
+ return;
+ }
+
+ // The network is up and we don't know that we're in a locked portal.
+ // Run heuristics. If we detect a portal later, we'll run heuristics again
+ // when it's unlocked. In that case, this run will likely have failed.
+ await this.runHeuristics("netchange");
+ },
+
+ async onConnectivityAvailable() {
+ if (this._debounceTimer) {
+ // Already debouncing - nothing to do.
+ return;
+ }
+
+ await this.runHeuristics("connectivity");
+ },
+};