summaryrefslogtreecommitdiffstats
path: root/browser/components/doh
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/doh
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/doh')
-rw-r--r--browser/components/doh/DoHConfig.jsm76
-rw-r--r--browser/components/doh/DoHController.jsm615
-rw-r--r--browser/components/doh/DoHHeuristics.jsm402
-rw-r--r--browser/components/doh/TRRPerformance.jsm412
-rw-r--r--browser/components/doh/moz.build18
-rw-r--r--browser/components/doh/test/browser/browser.ini16
-rw-r--r--browser/components/doh/test/browser/browser_NextDNSMigration.js47
-rw-r--r--browser/components/doh/test/browser/browser_cleanFlow.js87
-rw-r--r--browser/components/doh/test/browser/browser_dirtyEnable.js55
-rw-r--r--browser/components/doh/test/browser/browser_doorhangerUserReject.js71
-rw-r--r--browser/components/doh/test/browser/browser_localStorageMigration.js61
-rw-r--r--browser/components/doh/test/browser/browser_platformDetection.js73
-rw-r--r--browser/components/doh/test/browser/browser_policyOverride.js66
-rw-r--r--browser/components/doh/test/browser/browser_providerSteering.js100
-rw-r--r--browser/components/doh/test/browser/browser_rollback.js144
-rw-r--r--browser/components/doh/test/browser/browser_trrMode_migration.js33
-rw-r--r--browser/components/doh/test/browser/browser_trrSelect.js150
-rw-r--r--browser/components/doh/test/browser/browser_trrSelection_disable.js70
-rw-r--r--browser/components/doh/test/browser/browser_userInterference.js81
-rw-r--r--browser/components/doh/test/browser/head.js312
-rw-r--r--browser/components/doh/test/unit/head.js117
-rw-r--r--browser/components/doh/test/unit/test_DNSLookup.js62
-rw-r--r--browser/components/doh/test/unit/test_LookupAggregator.js160
-rw-r--r--browser/components/doh/test/unit/test_TRRRacer.js209
-rw-r--r--browser/components/doh/test/unit/xpcshell.ini10
25 files changed, 3447 insertions, 0 deletions
diff --git a/browser/components/doh/DoHConfig.jsm b/browser/components/doh/DoHConfig.jsm
new file mode 100644
index 0000000000..1c3852bab9
--- /dev/null
+++ b/browser/components/doh/DoHConfig.jsm
@@ -0,0 +1,76 @@
+/* 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 provides an interface to acces DoH config settings - e.g. whether
+ * DoH is enabled, whether capabilities are enabled, etc. Currently this just
+ * provides getters for prefs, but imminently will be extended to read config
+ * from a Remote Settings collection and filter by client region etc.
+ */
+var EXPORTED_SYMBOLS = ["Config"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const kEnabledPref = "doh-rollout.enabled";
+
+const kTRRSelectionEnabledPref = "doh-rollout.trr-selection.enabled";
+const kTRRSelectionCommitResultPref = "doh-rollout.trr-selection.commit-result";
+
+const kProviderSteeringEnabledPref = "doh-rollout.provider-steering.enabled";
+const kProviderSteeringListPref = "doh-rollout.provider-steering.provider-list";
+
+const kPrefChangedTopic = "nsPref:changed";
+
+const Config = {
+ init() {
+ Preferences.observe(kEnabledPref, this);
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case kPrefChangedTopic:
+ this.notifyNewConfig();
+ break;
+ }
+ },
+
+ kConfigUpdateTopic: "doh-config-updated",
+ notifyNewConfig() {
+ Services.obs.notifyObservers(null, this.kConfigUpdateTopic);
+ },
+
+ get enabled() {
+ return Preferences.get(kEnabledPref, false);
+ },
+
+ trrSelection: {
+ get enabled() {
+ return Preferences.get(kTRRSelectionEnabledPref, false);
+ },
+
+ get commitResult() {
+ return Preferences.get(kTRRSelectionCommitResultPref, false);
+ },
+ },
+
+ providerSteering: {
+ get enabled() {
+ return Preferences.get(kProviderSteeringEnabledPref, false);
+ },
+
+ get providerList() {
+ return Preferences.get(kProviderSteeringListPref, "[]");
+ },
+ },
+};
+
+Config.init();
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");
+ },
+};
diff --git a/browser/components/doh/DoHHeuristics.jsm b/browser/components/doh/DoHHeuristics.jsm
new file mode 100644
index 0000000000..6fd7770c5a
--- /dev/null
+++ b/browser/components/doh/DoHHeuristics.jsm
@@ -0,0 +1,402 @@
+/* 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 implements the heuristics used to determine whether to enable
+ * or disable DoH on different networks. DoHController is responsible for running
+ * these at startup and upon network changes.
+ */
+var EXPORTED_SYMBOLS = ["Heuristics"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gDNSService",
+ "@mozilla.org/network/dns-service;1",
+ "nsIDNSService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gNetworkLinkService",
+ "@mozilla.org/network/network-link-service;1",
+ "nsINetworkLinkService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gParentalControlsService",
+ "@mozilla.org/parental-controls-service;1",
+ "nsIParentalControlsService"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Config",
+ "resource:///modules/DoHConfig.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const GLOBAL_CANARY = "use-application-dns.net.";
+
+const NXDOMAIN_ERR = "NS_ERROR_UNKNOWN_HOST";
+
+const Heuristics = {
+ // String constants used to indicate outcome of heuristics.
+ ENABLE_DOH: "enable_doh",
+ DISABLE_DOH: "disable_doh",
+
+ async run() {
+ // Run all the heuristics at the same time.
+ let [safeSearchChecks, zscaler, canary] = await Promise.all([
+ safeSearch(),
+ zscalerCanary(),
+ globalCanary(),
+ ]);
+
+ let platformChecks = await platform();
+ let results = {
+ google: safeSearchChecks.google,
+ youtube: safeSearchChecks.youtube,
+ zscalerCanary: zscaler,
+ canary,
+ modifiedRoots: await modifiedRoots(),
+ browserParent: await parentalControls(),
+ thirdPartyRoots: await thirdPartyRoots(),
+ policy: await enterprisePolicy(),
+ vpn: platformChecks.vpn,
+ proxy: platformChecks.proxy,
+ nrpt: platformChecks.nrpt,
+ steeredProvider: "",
+ };
+
+ // If any of those were triggered, return the results immediately.
+ if (Object.values(results).includes("disable_doh")) {
+ return results;
+ }
+
+ // Check for provider steering only after the other heuristics have passed.
+ results.steeredProvider = (await providerSteering()) || "";
+ return results;
+ },
+
+ async checkEnterprisePolicy() {
+ return enterprisePolicy();
+ },
+
+ // Test only
+ async _setMockLinkService(mockLinkService) {
+ this.mockLinkService = mockLinkService;
+ },
+};
+
+async function dnsLookup(hostname, resolveCanonicalName = false) {
+ let lookupPromise = new Promise((resolve, reject) => {
+ let request;
+ let response = {
+ addresses: [],
+ };
+ let listener = {
+ onLookupComplete(inRequest, inRecord, inStatus) {
+ if (inRequest === request) {
+ if (!Components.isSuccessCode(inStatus)) {
+ reject({ message: new Components.Exception("", inStatus).name });
+ return;
+ }
+ inRecord.QueryInterface(Ci.nsIDNSAddrRecord);
+ if (resolveCanonicalName) {
+ try {
+ response.canonicalName = inRecord.canonicalName;
+ } catch (e) {
+ // no canonicalName
+ }
+ }
+ while (inRecord.hasMore()) {
+ let addr = inRecord.getNextAddrAsString();
+ // Sometimes there are duplicate records with the same ip.
+ if (!response.addresses.includes(addr)) {
+ response.addresses.push(addr);
+ }
+ }
+ resolve(response);
+ }
+ },
+ };
+ let dnsFlags =
+ Ci.nsIDNSService.RESOLVE_TRR_DISABLED_MODE |
+ Ci.nsIDNSService.RESOLVE_DISABLE_IPV6 |
+ Ci.nsIDNSService.RESOLVE_BYPASS_CACHE |
+ Ci.nsIDNSService.RESOLVE_CANONICAL_NAME;
+ try {
+ request = gDNSService.asyncResolve(
+ hostname,
+ Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
+ dnsFlags,
+ null,
+ listener,
+ null,
+ {} /* defaultOriginAttributes */
+ );
+ } catch (e) {
+ // handle exceptions such as offline mode.
+ reject({ message: e.name });
+ }
+ });
+
+ let addresses, canonicalName, err;
+
+ try {
+ let response = await lookupPromise;
+ addresses = response.addresses;
+ canonicalName = response.canonicalName;
+ } catch (e) {
+ addresses = [null];
+ err = e.message;
+ }
+
+ return { addresses, canonicalName, err };
+}
+
+async function dnsListLookup(domainList) {
+ let results = [];
+
+ let resolutions = await Promise.all(
+ domainList.map(domain => dnsLookup(domain))
+ );
+ for (let { addresses } of resolutions) {
+ results = results.concat(addresses);
+ }
+
+ return results;
+}
+
+// TODO: Confirm the expected behavior when filtering is on
+async function globalCanary() {
+ let { addresses, err } = await dnsLookup(GLOBAL_CANARY);
+
+ if (err === NXDOMAIN_ERR || !addresses.length) {
+ return "disable_doh";
+ }
+
+ return "enable_doh";
+}
+
+async function modifiedRoots() {
+ // Check for presence of enterprise_roots cert pref. If enabled, disable DoH
+ let rootsEnabled = Preferences.get(
+ "security.enterprise_roots.enabled",
+ false
+ );
+
+ if (rootsEnabled) {
+ return "disable_doh";
+ }
+
+ return "enable_doh";
+}
+
+async function parentalControls() {
+ if (Cu.isInAutomation) {
+ return "enable_doh";
+ }
+
+ if (gParentalControlsService.parentalControlsEnabled) {
+ return "disable_doh";
+ }
+ return "enable_doh";
+}
+
+async function thirdPartyRoots() {
+ if (Cu.isInAutomation) {
+ return "enable_doh";
+ }
+
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+
+ let hasThirdPartyRoots = await new Promise(resolve => {
+ certdb.asyncHasThirdPartyRoots(resolve);
+ });
+
+ if (hasThirdPartyRoots) {
+ return "disable_doh";
+ }
+
+ return "enable_doh";
+}
+
+async function enterprisePolicy() {
+ if (Services.policies.status === Services.policies.ACTIVE) {
+ let policies = Services.policies.getActivePolicies();
+
+ if (!policies.hasOwnProperty("DNSOverHTTPS")) {
+ // If DoH isn't in the policy, return that there is a policy (but no DoH specifics)
+ return "policy_without_doh";
+ }
+
+ if (policies.DNSOverHTTPS.Enabled === true) {
+ // If DoH is enabled in the policy, enable it
+ return "enable_doh";
+ }
+
+ // If DoH is disabled in the policy, disable it
+ return "disable_doh";
+ }
+
+ // Default return, meaning no policy related to DNSOverHTTPS
+ return "no_policy_set";
+}
+
+async function safeSearch() {
+ const providerList = [
+ {
+ name: "google",
+ unfiltered: ["www.google.com.", "google.com."],
+ safeSearch: ["forcesafesearch.google.com."],
+ },
+ {
+ name: "youtube",
+ unfiltered: [
+ "www.youtube.com.",
+ "m.youtube.com.",
+ "youtubei.googleapis.com.",
+ "youtube.googleapis.com.",
+ "www.youtube-nocookie.com.",
+ ],
+ safeSearch: ["restrict.youtube.com.", "restrictmoderate.youtube.com."],
+ },
+ ];
+
+ async function checkProvider(provider) {
+ let [unfilteredAnswers, safeSearchAnswers] = await Promise.all([
+ dnsListLookup(provider.unfiltered),
+ dnsListLookup(provider.safeSearch),
+ ]);
+
+ // Given a provider, check if the answer for any safe search domain
+ // matches the answer for any default domain
+ for (let answer of safeSearchAnswers) {
+ if (answer && unfilteredAnswers.includes(answer)) {
+ return { name: provider.name, result: "disable_doh" };
+ }
+ }
+
+ return { name: provider.name, result: "enable_doh" };
+ }
+
+ // Compare strict domain lookups to non-strict domain lookups.
+ // Resolutions has a type of [{ name, result }]
+ let resolutions = await Promise.all(
+ providerList.map(provider => checkProvider(provider))
+ );
+
+ // Reduce that array entries into a single map
+ return resolutions.reduce(
+ (accumulator, check) => {
+ accumulator[check.name] = check.result;
+ return accumulator;
+ },
+ {} // accumulator
+ );
+}
+
+async function zscalerCanary() {
+ const ZSCALER_CANARY = "sitereview.zscaler.com.";
+
+ let { addresses } = await dnsLookup(ZSCALER_CANARY);
+ for (let address of addresses) {
+ if (
+ ["213.152.228.242", "199.168.151.251", "8.25.203.30"].includes(address)
+ ) {
+ // if sitereview.zscaler.com resolves to either one of the 3 IPs above,
+ // Zscaler Shift service is in use, don't enable DoH
+ return "disable_doh";
+ }
+ }
+
+ return "enable_doh";
+}
+
+async function platform() {
+ let platformChecks = {};
+
+ let indications = Ci.nsINetworkLinkService.NONE_DETECTED;
+ try {
+ let linkService = gNetworkLinkService;
+ if (Heuristics.mockLinkService) {
+ linkService = Heuristics.mockLinkService;
+ }
+ indications = linkService.platformDNSIndications;
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_IMPLEMENTED) {
+ Cu.reportError(e);
+ }
+ }
+
+ platformChecks.vpn =
+ indications & Ci.nsINetworkLinkService.VPN_DETECTED
+ ? "disable_doh"
+ : "enable_doh";
+ platformChecks.proxy =
+ indications & Ci.nsINetworkLinkService.PROXY_DETECTED
+ ? "disable_doh"
+ : "enable_doh";
+ platformChecks.nrpt =
+ indications & Ci.nsINetworkLinkService.NRPT_DETECTED
+ ? "disable_doh"
+ : "enable_doh";
+
+ return platformChecks;
+}
+
+// Check if the network provides a DoH endpoint to use. Returns the name of the
+// provider if the check is successful, else null. Currently we only support
+// this for Comcast networks.
+async function providerSteering() {
+ if (!Config.providerSteering.enabled) {
+ return null;
+ }
+ const TEST_DOMAIN = "doh.test.";
+
+ // Array of { name, canonicalName, uri } where name is an identifier for
+ // telemetry, canonicalName is the expected CNAME when looking up doh.test,
+ // and uri is the provider's DoH endpoint.
+ let steeredProviders = Config.providerSteering.providerList;
+ try {
+ steeredProviders = JSON.parse(steeredProviders);
+ } catch (e) {
+ console.log("Provider list is invalid JSON, moving on.");
+ return null;
+ }
+
+ if (!steeredProviders || !steeredProviders.length) {
+ return null;
+ }
+
+ let { canonicalName, err } = await dnsLookup(TEST_DOMAIN, true);
+ if (err || !canonicalName) {
+ return null;
+ }
+
+ let provider = steeredProviders.find(p => {
+ return p.canonicalName == canonicalName;
+ });
+ if (!provider || !provider.uri || !provider.name) {
+ return null;
+ }
+
+ return provider;
+}
diff --git a/browser/components/doh/TRRPerformance.jsm b/browser/components/doh/TRRPerformance.jsm
new file mode 100644
index 0000000000..6ced2c33e4
--- /dev/null
+++ b/browser/components/doh/TRRPerformance.jsm
@@ -0,0 +1,412 @@
+/* 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 tests TRR performance by issuing DNS requests to TRRs and
+ * recording telemetry for the network time for each request.
+ *
+ * We test each TRR with 5 random subdomains of a canonical domain and also
+ * a "popular" domain (which the TRR likely have cached).
+ *
+ * To ensure data integrity, we run the requests in an aggregator wrapper
+ * and collect all the results before sending telemetry. If we detect network
+ * loss, the results are discarded. A new run is triggered upon detection of
+ * usable network until a full set of results has been captured. We stop retrying
+ * after 5 attempts.
+ */
+var EXPORTED_SYMBOLS = ["TRRRacer", "DNSLookup", "LookupAggregator"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+Services.telemetry.setEventRecordingEnabled(
+ "security.doh.trrPerformance",
+ true
+);
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gNetworkLinkService",
+ "@mozilla.org/network/network-link-service;1",
+ "nsINetworkLinkService"
+);
+
+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,
+ "gUUIDGenerator",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator"
+);
+
+// The list of participating TRRs.
+const kTRRs = JSON.parse(
+ Services.prefs.getDefaultBranch("").getCharPref("network.trr.resolvers")
+).map(trr => trr.url);
+
+// The canonical domain whose subdomains we will be resolving.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "kCanonicalDomain",
+ "doh-rollout.trrRace.canonicalDomain",
+ "firefox-dns-perf-test.net."
+);
+
+// The number of random subdomains to resolve per TRR.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "kRepeats",
+ "doh-rollout.trrRace.randomSubdomainCount",
+ 5
+);
+
+// The "popular" domain that we expect the TRRs to have cached.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "kPopularDomains",
+ "doh-rollout.trrRace.popularDomains",
+ null,
+ null,
+ val =>
+ val
+ ? val.split(",").map(t => t.trim())
+ : [
+ "google.com.",
+ "youtube.com.",
+ "amazon.com.",
+ "facebook.com.",
+ "yahoo.com.",
+ ]
+);
+
+function getRandomSubdomain() {
+ let uuid = gUUIDGenerator
+ .generateUUID()
+ .toString()
+ .slice(1, -1); // Discard surrounding braces
+ return `${uuid}.${kCanonicalDomain}`;
+}
+
+// A wrapper around async DNS lookups. The results are passed on to the supplied
+// callback. The wrapper attempts the lookup 3 times before passing on a failure.
+// If a false-y `domain` is supplied, a random subdomain will be used. Each retry
+// will use a different random subdomain to ensure we bypass chached responses.
+class DNSLookup {
+ constructor(domain, trrServer, callback) {
+ this._domain = domain;
+ this.trrServer = trrServer;
+ this.callback = callback;
+ this.retryCount = 0;
+ }
+
+ doLookup() {
+ this.retryCount++;
+ try {
+ this.usedDomain = this._domain || getRandomSubdomain();
+ gDNSService.asyncResolve(
+ this.usedDomain,
+ Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
+ Ci.nsIDNSService.RESOLVE_BYPASS_CACHE,
+ gDNSService.newTRRResolverInfo(this.trrServer),
+ this,
+ Services.tm.currentThread,
+ {}
+ );
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+
+ onLookupComplete(request, record, status) {
+ // Try again if we failed...
+ if (!Components.isSuccessCode(status) && this.retryCount < 3) {
+ this.doLookup();
+ return;
+ }
+
+ // But after the third try, just pass the status on.
+ this.callback(request, record, status, this.usedDomain, this.retryCount);
+ }
+}
+
+DNSLookup.prototype.QueryInterface = ChromeUtils.generateQI(["nsIDNSListener"]);
+
+// A wrapper around a single set of measurements. The required lookups are
+// triggered and the results aggregated before telemetry is sent. If aborted,
+// any aggregated results are discarded.
+class LookupAggregator {
+ constructor(onCompleteCallback) {
+ this.onCompleteCallback = onCompleteCallback;
+ this.aborted = false;
+ this.networkUnstable = false;
+ this.captivePortal = false;
+
+ this.domains = [];
+ for (let i = 0; i < kRepeats; ++i) {
+ // false-y domain will cause DNSLookup to generate a random one.
+ this.domains.push(null);
+ }
+ this.domains.push(...kPopularDomains);
+ this.totalLookups = kTRRs.length * this.domains.length;
+ this.completedLookups = 0;
+ this.results = [];
+ }
+
+ run() {
+ if (this._ran || this._aborted) {
+ Cu.reportError("Trying to re-run a LookupAggregator.");
+ return;
+ }
+
+ this._ran = true;
+ for (let trr of kTRRs) {
+ for (let domain of this.domains) {
+ new DNSLookup(
+ domain,
+ trr,
+ (request, record, status, usedDomain, retryCount) => {
+ this.results.push({
+ domain: usedDomain,
+ trr,
+ status,
+ time: record
+ ? record.QueryInterface(Ci.nsIDNSAddrRecord)
+ .trrFetchDurationNetworkOnly
+ : -1,
+ retryCount,
+ });
+
+ this.completedLookups++;
+ if (this.completedLookups == this.totalLookups) {
+ this.recordResults();
+ }
+ }
+ ).doLookup();
+ }
+ }
+ }
+
+ abort() {
+ this.aborted = true;
+ }
+
+ markUnstableNetwork() {
+ this.networkUnstable = true;
+ }
+
+ markCaptivePortal() {
+ this.captivePortal = true;
+ }
+
+ recordResults() {
+ if (this.aborted) {
+ return;
+ }
+
+ for (let { domain, trr, status, time, retryCount } of this.results) {
+ if (
+ !(kPopularDomains.includes(domain) || domain.includes(kCanonicalDomain))
+ ) {
+ Cu.reportError("Expected known domain for reporting, got " + domain);
+ return;
+ }
+
+ Services.telemetry.recordEvent(
+ "security.doh.trrPerformance",
+ "resolved",
+ "record",
+ "success",
+ {
+ domain,
+ trr,
+ status: status.toString(),
+ time: time.toString(),
+ retryCount: retryCount.toString(),
+ networkUnstable: this.networkUnstable.toString(),
+ captivePortal: this.captivePortal.toString(),
+ }
+ );
+ }
+
+ this.onCompleteCallback();
+ }
+}
+
+// This class monitors the network and spawns a new LookupAggregator when ready.
+// When the network goes down, an ongoing aggregator is aborted and a new one
+// spawned next time we get a link, up to 5 times. On the fifth time, we just
+// let the aggegator complete and mark it as tainted.
+class TRRRacer {
+ constructor(onCompleteCallback) {
+ this._aggregator = null;
+ this._retryCount = 0;
+ this._complete = false;
+ this._onCompleteCallback = onCompleteCallback;
+ }
+
+ run() {
+ if (
+ gNetworkLinkService.isLinkUp &&
+ gCaptivePortalService.state != gCaptivePortalService.LOCKED_PORTAL
+ ) {
+ this._runNewAggregator();
+ if (
+ gCaptivePortalService.state == gCaptivePortalService.UNLOCKED_PORTAL
+ ) {
+ this._aggregator.markCaptivePortal();
+ }
+ }
+
+ Services.obs.addObserver(this, "ipc:network:captive-portal-set-state");
+ Services.obs.addObserver(this, "network:link-status-changed");
+ }
+
+ onComplete() {
+ Services.obs.removeObserver(this, "ipc:network:captive-portal-set-state");
+ Services.obs.removeObserver(this, "network:link-status-changed");
+
+ this._complete = true;
+
+ if (this._onCompleteCallback) {
+ this._onCompleteCallback();
+ }
+ }
+
+ getFastestTRR(returnRandomDefault = false) {
+ if (!this._complete) {
+ throw new Error("getFastestTRR: Measurement still running.");
+ }
+
+ return this._getFastestTRRFromResults(
+ this._aggregator.results,
+ returnRandomDefault
+ );
+ }
+
+ /*
+ * Given an array of { trr, time }, returns the trr with smallest mean time.
+ * Separate from _getFastestTRR for easy unit-testing.
+ *
+ * @returns The TRR with the fastest average time.
+ * If returnRandomDefault is false-y, returns undefined if no valid
+ * times were present in the results. Otherwise, returns one of the
+ * present TRRs at random.
+ */
+ _getFastestTRRFromResults(results, returnRandomDefault = false) {
+ // First, organize the results into a map of TRR -> array of times
+ let TRRTimingMap = new Map();
+ let TRRErrorCount = new Map();
+ for (let { trr, time } of results) {
+ if (!TRRTimingMap.has(trr)) {
+ TRRTimingMap.set(trr, []);
+ }
+ if (time != -1) {
+ TRRTimingMap.get(trr).push(time);
+ } else {
+ TRRErrorCount.set(trr, 1 + (TRRErrorCount.get(trr) || 0));
+ }
+ }
+
+ // Loop through each TRR's array of times, compute the geometric means,
+ // and remember the fastest TRR. Geometric mean is a bit more forgiving
+ // in the presence of noise (anomalously high values).
+ // We don't need the full geometric mean, we simply calculate the arithmetic
+ // means in log-space and then compare those values.
+ let fastestTRR;
+ let fastestAverageTime = -1;
+ let trrs = [...TRRTimingMap.keys()];
+ for (let trr of trrs) {
+ let times = TRRTimingMap.get(trr);
+ if (!times.length) {
+ continue;
+ }
+
+ // Skip TRRs that had an error rate of more than 30%.
+ let errorCount = TRRErrorCount.get(trr) || 0;
+ let totalResults = times.length + errorCount;
+ if (errorCount / totalResults > 0.3) {
+ continue;
+ }
+
+ // Arithmetic mean in log space. Take log of (a + 1) to ensure we never
+ // take log(0) which would be -Infinity.
+ let averageTime =
+ times.map(a => Math.log(a + 1)).reduce((a, b) => a + b) / times.length;
+ if (fastestAverageTime == -1 || averageTime < fastestAverageTime) {
+ fastestAverageTime = averageTime;
+ fastestTRR = trr;
+ }
+ }
+
+ if (returnRandomDefault && !fastestTRR) {
+ fastestTRR = trrs[Math.floor(Math.random() * trrs.length)];
+ }
+
+ return fastestTRR;
+ }
+
+ _runNewAggregator() {
+ this._aggregator = new LookupAggregator(() => this.onComplete());
+ this._aggregator.run();
+ this._retryCount++;
+ }
+
+ // When the link goes *down*, or when we detect a locked captive portal, we
+ // abort any ongoing LookupAggregator run. When the link goes *up*, or we
+ // detect a newly unlocked portal, we start a run if one isn't ongoing.
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "network:link-status-changed":
+ if (this._aggregator && data == "down") {
+ if (this._retryCount < 5) {
+ this._aggregator.abort();
+ } else {
+ this._aggregator.markUnstableNetwork();
+ }
+ } else if (
+ data == "up" &&
+ (!this._aggregator || this._aggregator.aborted)
+ ) {
+ this._runNewAggregator();
+ }
+ break;
+ case "ipc:network:captive-portal-set-state":
+ if (
+ this._aggregator &&
+ gCaptivePortalService.state == gCaptivePortalService.LOCKED_PORTAL
+ ) {
+ if (this._retryCount < 5) {
+ this._aggregator.abort();
+ } else {
+ this._aggregator.markCaptivePortal();
+ }
+ } else if (
+ gCaptivePortalService.state ==
+ gCaptivePortalService.UNLOCKED_PORTAL &&
+ (!this._aggregator || this._aggregator.aborted)
+ ) {
+ this._runNewAggregator();
+ }
+ break;
+ }
+ }
+}
diff --git a/browser/components/doh/moz.build b/browser/components/doh/moz.build
new file mode 100644
index 0000000000..0cdc2dff92
--- /dev/null
+++ b/browser/components/doh/moz.build
@@ -0,0 +1,18 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Security")
+
+EXTRA_JS_MODULES += [
+ "DoHConfig.jsm",
+ "DoHController.jsm",
+ "DoHHeuristics.jsm",
+ "TRRPerformance.jsm",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
diff --git a/browser/components/doh/test/browser/browser.ini b/browser/components/doh/test/browser/browser.ini
new file mode 100644
index 0000000000..e7904a7d20
--- /dev/null
+++ b/browser/components/doh/test/browser/browser.ini
@@ -0,0 +1,16 @@
+[DEFAULT]
+head = head.js
+
+[browser_cleanFlow.js]
+[browser_dirtyEnable.js]
+[browser_doorhangerUserReject.js]
+[browser_localStorageMigration.js]
+[browser_NextDNSMigration.js]
+[browser_policyOverride.js]
+[browser_providerSteering.js]
+[browser_rollback.js]
+[browser_trrMode_migration.js]
+[browser_trrSelect.js]
+[browser_trrSelection_disable.js]
+[browser_userInterference.js]
+[browser_platformDetection.js]
diff --git a/browser/components/doh/test/browser/browser_NextDNSMigration.js b/browser/components/doh/test/browser/browser_NextDNSMigration.js
new file mode 100644
index 0000000000..a54c0fb999
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_NextDNSMigration.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testNextDNSMigration() {
+ let oldURL = "https://trr.dns.nextdns.io/";
+ let newURL = "https://firefox.dns.nextdns.io/";
+
+ let prefChangePromises = [];
+ let prefsToMigrate = {
+ "network.trr.resolvers": `[{ "name": "Other Provider", "url": "https://sometrr.com/query" }, { "name": "NextDNS", "url": "${oldURL}" }]`,
+ "network.trr.uri": oldURL,
+ "network.trr.custom_uri": oldURL,
+ "doh-rollout.trr-selection.dry-run-result": oldURL,
+ "doh-rollout.uri": oldURL,
+ };
+
+ for (let [pref, value] of Object.entries(prefsToMigrate)) {
+ Preferences.set(pref, value);
+
+ prefChangePromises.push(
+ new Promise(resolve => {
+ Preferences.observe(pref, function obs() {
+ Preferences.ignore(pref, obs);
+ resolve();
+ });
+ })
+ );
+ }
+
+ let migrationDone = Promise.all(prefChangePromises);
+ await restartDoHController();
+ await migrationDone;
+
+ for (let [pref, value] of Object.entries(prefsToMigrate)) {
+ is(
+ Preferences.get(pref),
+ value.replaceAll(oldURL, newURL),
+ "Pref correctly migrated"
+ );
+ Preferences.reset(pref);
+ }
+});
diff --git a/browser/components/doh/test/browser/browser_cleanFlow.js b/browser/components/doh/test/browser/browser_cleanFlow.js
new file mode 100644
index 0000000000..f6e8018e20
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_cleanFlow.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testCleanFlow() {
+ // Set up a passing environment and enable DoH.
+ setPassingHeuristics();
+ let promise = waitForDoorhanger();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF);
+ Preferences.set(prefs.ENABLED_PREF, true);
+
+ await prefPromise;
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete."
+ );
+ await checkTRRSelectionTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL);
+ let panel = await promise;
+
+ prefPromise = TestUtils.waitForPrefChange(
+ prefs.DOORHANGER_USER_DECISION_PREF
+ );
+
+ // Click the doorhanger's "accept" button.
+ let button = panel.querySelector(".popup-notification-primary-button");
+ promise = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(button, {});
+ await promise;
+
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ await prefPromise;
+ is(
+ Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF),
+ "UIOk",
+ "Doorhanger decision saved."
+ );
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared.");
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Change the environment to failing and simulate a network change.
+ setFailingHeuristics();
+ simulateNetworkChange();
+ await ensureTRRMode(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ // Trigger another network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ // Restart the controller for good measure.
+ await restartDoHController();
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoTRRModeChange(0);
+ await checkHeuristicsTelemetry("disable_doh", "startup");
+
+ // Set a passing environment and simulate a network change.
+ setPassingHeuristics();
+ simulateNetworkChange();
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "netchange");
+
+ // Again, repeat and check nothing changed.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(2);
+ await checkHeuristicsTelemetry("enable_doh", "netchange");
+
+ // Test the clearModeOnShutdown pref. `restartDoHController` does the actual
+ // test for us between shutdown and startup.
+ Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, false);
+ await restartDoHController();
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoTRRModeChange(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+ Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, true);
+});
diff --git a/browser/components/doh/test/browser/browser_dirtyEnable.js b/browser/components/doh/test/browser/browser_dirtyEnable.js
new file mode 100644
index 0000000000..c704ca06e6
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_dirtyEnable.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testDirtyEnable() {
+ // Set up a failing environment, pre-set DoH to enabled, and verify that
+ // when the add-on is enabled, it doesn't do anything - DoH remains turned on.
+ setFailingHeuristics();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.DISABLED_PREF);
+ Preferences.set(prefs.NETWORK_TRR_MODE_PREF, 2);
+ Preferences.set(prefs.ENABLED_PREF, true);
+ await prefPromise;
+ is(
+ Preferences.get(prefs.DISABLED_PREF, false),
+ true,
+ "Disabled state recorded."
+ );
+ is(
+ Preferences.get(prefs.BREADCRUMB_PREF),
+ undefined,
+ "Breadcrumb not saved."
+ );
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ undefined,
+ "TRR selection not performed."
+ );
+ is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved.");
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+
+ // Simulate a network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+ is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved.");
+
+ // Restart the controller for good measure.
+ await restartDoHController();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoTRRSelectionTelemetry();
+ ensureNoHeuristicsTelemetry();
+ is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved.");
+
+ // Simulate a network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved.");
+ ensureNoHeuristicsTelemetry();
+});
diff --git a/browser/components/doh/test/browser/browser_doorhangerUserReject.js b/browser/components/doh/test/browser/browser_doorhangerUserReject.js
new file mode 100644
index 0000000000..d887d43c05
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_doorhangerUserReject.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testDoorhangerUserReject() {
+ // Set up a passing environment and enable DoH.
+ setPassingHeuristics();
+ let promise = waitForDoorhanger();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF);
+ Preferences.set(prefs.ENABLED_PREF, true);
+
+ await prefPromise;
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete."
+ );
+ await checkTRRSelectionTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL);
+ let panel = await promise;
+
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ prefPromise = TestUtils.waitForPrefChange(
+ prefs.DOORHANGER_USER_DECISION_PREF
+ );
+
+ // Click the doorhanger's "reject" button.
+ let button = panel.querySelector(".popup-notification-secondary-button");
+ promise = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(button, {});
+ await promise;
+
+ await prefPromise;
+
+ is(
+ Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF),
+ "UIDisabled",
+ "Doorhanger decision saved."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ await ensureTRRMode(undefined);
+ ensureNoHeuristicsTelemetry();
+ is(Preferences.get(prefs.BREADCRUMB_PREF), undefined, "Breadcrumb cleared.");
+
+ // Simulate a network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+
+ // Restart the controller for good measure.
+ await restartDoHController();
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+
+ // Set failing environment and trigger another network change.
+ setFailingHeuristics();
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+});
diff --git a/browser/components/doh/test/browser/browser_localStorageMigration.js b/browser/components/doh/test/browser/browser_localStorageMigration.js
new file mode 100644
index 0000000000..34c8916bed
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_localStorageMigration.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionStorageIDB",
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+
+const ADDON_ID = "doh-rollout@mozilla.org";
+
+add_task(setup);
+
+add_task(async function testLocalStorageMigration() {
+ Preferences.reset(prefs.BALROG_MIGRATION_PREF);
+
+ const legacyEntries = {
+ doneFirstRun: true,
+ "doh-rollout.doorhanger-decision": "UIOk",
+ "doh-rollout.disable-heuristics": true,
+ };
+
+ let policy = WebExtensionPolicy.getByID(ADDON_ID);
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ policy.extension
+ );
+
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+ await idbConn.set(legacyEntries);
+
+ let migrationDone = new Promise(resolve => {
+ Preferences.observe(prefs.BALROG_MIGRATION_PREF, function obs() {
+ Preferences.ignore(prefs.BALROG_MIGRATION_PREF, obs);
+ resolve();
+ });
+ });
+
+ await restartDoHController();
+ await migrationDone;
+
+ for (let [key, value] of Object.entries(legacyEntries)) {
+ if (!key.startsWith("doh-rollout")) {
+ key = "doh-rollout." + key;
+ }
+
+ is(
+ Preferences.get(key),
+ value,
+ `${key} pref exists and has the right value ${value}`
+ );
+
+ Preferences.reset(key);
+ }
+
+ await idbConn.clear();
+ await idbConn.close();
+});
diff --git a/browser/components/doh/test/browser/browser_platformDetection.js b/browser/components/doh/test/browser/browser_platformDetection.js
new file mode 100644
index 0000000000..5e06e531fe
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_platformDetection.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Heuristics: "resource:///modules/DoHHeuristics.jsm",
+});
+
+add_task(setup);
+
+add_task(async function testPlatformIndications() {
+ // Check if the platform heuristics actually cause a "disable_doh" event
+
+ let { MockRegistrar } = ChromeUtils.import(
+ "resource://testing-common/MockRegistrar.jsm"
+ );
+
+ let mockedLinkService = {
+ isLinkUp: true,
+ linkStatusKnown: true,
+ linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI,
+ networkID: "abcd",
+ dnsSuffixList: [],
+ platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED,
+ QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]),
+ };
+
+ let networkLinkServiceCID = MockRegistrar.register(
+ "@mozilla.org/network/network-link-service;1",
+ mockedLinkService
+ );
+
+ Heuristics._setMockLinkService(mockedLinkService);
+ registerCleanupFunction(async () => {
+ MockRegistrar.unregister(networkLinkServiceCID);
+ Heuristics._setMockLinkService(undefined);
+ });
+
+ setPassingHeuristics();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF);
+ Preferences.set(prefs.ENABLED_PREF, true);
+ await prefPromise;
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ await ensureTRRMode(2);
+
+ mockedLinkService.platformDNSIndications =
+ Ci.nsINetworkLinkService.VPN_DETECTED;
+ simulateNetworkChange();
+ await ensureTRRMode(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ mockedLinkService.platformDNSIndications =
+ Ci.nsINetworkLinkService.PROXY_DETECTED;
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ mockedLinkService.platformDNSIndications =
+ Ci.nsINetworkLinkService.NRPT_DETECTED;
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ mockedLinkService.platformDNSIndications =
+ Ci.nsINetworkLinkService.NONE_DETECTED;
+ simulateNetworkChange();
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "netchange");
+});
diff --git a/browser/components/doh/test/browser/browser_policyOverride.js b/browser/components/doh/test/browser/browser_policyOverride.js
new file mode 100644
index 0000000000..e93c4632b9
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_policyOverride.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+const { EnterprisePolicyTesting } = ChromeUtils.import(
+ "resource://testing-common/EnterprisePolicyTesting.jsm"
+);
+
+add_task(async function testPolicyOverride() {
+ // Set up an arbitrary enterprise policy. Its existence should be sufficient
+ // to disable heuristics.
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ EnableTrackingProtection: {
+ Value: true,
+ },
+ },
+ });
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Policy engine is active."
+ );
+
+ Preferences.set(prefs.ENABLED_PREF, true);
+ await waitForStateTelemetry(["shutdown", "policyDisabled"]);
+ is(
+ Preferences.get(prefs.BREADCRUMB_PREF),
+ undefined,
+ "Breadcrumb not saved."
+ );
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ undefined,
+ "TRR selection not performed."
+ );
+ is(
+ Preferences.get(prefs.SKIP_HEURISTICS_PREF),
+ true,
+ "Pref set to suppress CFR."
+ );
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+
+ // Simulate a network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+
+ // Clean up.
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {},
+ });
+ EnterprisePolicyTesting.resetRunOnceState();
+
+ is(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.INACTIVE,
+ "Policy engine is inactive at the end of the test"
+ );
+});
diff --git a/browser/components/doh/test/browser/browser_providerSteering.js b/browser/components/doh/test/browser/browser_providerSteering.js
new file mode 100644
index 0000000000..b4d5217c7e
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_providerSteering.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const TEST_DOMAIN = "doh.test.";
+const AUTO_TRR_URI = "https://dummytrr.com/query";
+
+add_task(setup);
+
+add_task(async function testProviderSteering() {
+ setPassingHeuristics();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF);
+ Preferences.set(prefs.ENABLED_PREF, true);
+ await prefPromise;
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ let providerTestcases = [
+ {
+ name: "provider1",
+ canonicalName: "foo.provider1.com",
+ uri: "https://foo.provider1.com/query",
+ },
+ {
+ name: "provider2",
+ canonicalName: "bar.provider2.com",
+ uri: "https://bar.provider2.com/query",
+ },
+ ];
+ Preferences.set(
+ prefs.PROVIDER_STEERING_LIST_PREF,
+ JSON.stringify(providerTestcases)
+ );
+
+ let testNetChangeResult = async (
+ expectedURI,
+ heuristicsDecision,
+ providerName
+ ) => {
+ let trrURIChanged = TestUtils.topicObserved(
+ "network:trr-uri-changed",
+ () => {
+ // We need this check because this topic is observed once immediately
+ // after the network change when the URI is reset, and then when the
+ // provider steering heuristic runs and sets it to our uri.
+ return gDNSService.currentTrrURI == expectedURI;
+ }
+ );
+ simulateNetworkChange();
+ await trrURIChanged;
+ is(gDNSService.currentTrrURI, expectedURI, `TRR URI set to ${expectedURI}`);
+ await checkHeuristicsTelemetry(
+ heuristicsDecision,
+ "netchange",
+ providerName
+ );
+ };
+
+ for (let { name, canonicalName, uri } of providerTestcases) {
+ gDNSOverride.addIPOverride(TEST_DOMAIN, "9.9.9.9");
+ gDNSOverride.setCnameOverride(TEST_DOMAIN, canonicalName);
+ await testNetChangeResult(uri, "enable_doh", name);
+ gDNSOverride.clearHostOverride(TEST_DOMAIN);
+ }
+
+ await testNetChangeResult(AUTO_TRR_URI, "enable_doh");
+
+ // Just use the first provider for the remaining checks.
+ let provider = providerTestcases[0];
+ gDNSOverride.addIPOverride(TEST_DOMAIN, "9.9.9.9");
+ gDNSOverride.setCnameOverride(TEST_DOMAIN, provider.canonicalName);
+ await testNetChangeResult(provider.uri, "enable_doh", provider.name);
+
+ // Set enterprise roots enabled and ensure provider steering is disabled.
+ Preferences.set("security.enterprise_roots.enabled", true);
+ await testNetChangeResult(AUTO_TRR_URI, "disable_doh");
+ Preferences.reset("security.enterprise_roots.enabled");
+
+ // Check that provider steering is enabled again after we reset above.
+ await testNetChangeResult(provider.uri, "enable_doh", provider.name);
+
+ // Trigger safesearch heuristics and ensure provider steering is disabled.
+ let googleDomain = "google.com.";
+ let googleIP = "1.1.1.1";
+ let googleSafeSearchIP = "1.1.1.2";
+ gDNSOverride.clearHostOverride(googleDomain);
+ gDNSOverride.addIPOverride(googleDomain, googleSafeSearchIP);
+ await testNetChangeResult(AUTO_TRR_URI, "disable_doh");
+ gDNSOverride.clearHostOverride(googleDomain);
+ gDNSOverride.addIPOverride(googleDomain, googleIP);
+
+ // Check that provider steering is enabled again after we reset above.
+ await testNetChangeResult(provider.uri, "enable_doh", provider.name);
+
+ // Finally, provider steering should be disabled once we clear the override.
+ gDNSOverride.clearHostOverride(TEST_DOMAIN);
+ await testNetChangeResult(AUTO_TRR_URI, "enable_doh");
+});
diff --git a/browser/components/doh/test/browser/browser_rollback.js b/browser/components/doh/test/browser/browser_rollback.js
new file mode 100644
index 0000000000..a98ac6a017
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_rollback.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+add_task(setup);
+
+add_task(async function testRollback() {
+ // Set up a passing environment and enable DoH.
+ setPassingHeuristics();
+ let promise = waitForDoorhanger();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF);
+ Preferences.set(prefs.ENABLED_PREF, true);
+
+ await prefPromise;
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete."
+ );
+ await checkTRRSelectionTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL);
+ let panel = await promise;
+
+ prefPromise = TestUtils.waitForPrefChange(
+ prefs.DOORHANGER_USER_DECISION_PREF
+ );
+
+ // Click the doorhanger's "accept" button.
+ let button = panel.querySelector(".popup-notification-primary-button");
+ promise = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(button, {});
+ await promise;
+
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ await prefPromise;
+ is(
+ Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF),
+ "UIOk",
+ "Doorhanger decision saved."
+ );
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared.");
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Change the environment to failing and simulate a network change.
+ setFailingHeuristics();
+ simulateNetworkChange();
+ await ensureTRRMode(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ // Trigger another network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ // Rollback!
+ setPassingHeuristics();
+ Preferences.reset(prefs.ENABLED_PREF);
+ await waitForStateTelemetry(["shutdown", "rollback"]);
+ await ensureTRRMode(undefined);
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoHeuristicsTelemetry();
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ await ensureNoHeuristicsTelemetry();
+
+ // Re-enable.
+ Preferences.set(prefs.ENABLED_PREF, true);
+
+ await ensureTRRMode(2);
+ ensureNoTRRSelectionTelemetry();
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ // Change the environment to failing and simulate a network change.
+ setFailingHeuristics();
+ simulateNetworkChange();
+ await ensureTRRMode(0);
+ await checkHeuristicsTelemetry("disable_doh", "netchange");
+
+ // Rollback again for good measure! This time with failing heuristics.
+ Preferences.reset(prefs.ENABLED_PREF);
+ await waitForStateTelemetry(["shutdown", "rollback"]);
+ await ensureTRRMode(undefined);
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoHeuristicsTelemetry();
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ await ensureNoHeuristicsTelemetry();
+
+ // Re-enable.
+ Preferences.set(prefs.ENABLED_PREF, true);
+
+ await ensureTRRMode(0);
+ ensureNoTRRSelectionTelemetry();
+ await checkHeuristicsTelemetry("disable_doh", "startup");
+
+ // Change the environment to passing and simulate a network change.
+ setPassingHeuristics();
+ simulateNetworkChange();
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "netchange");
+
+ // Rollback again, this time with TRR mode set to 2 prior to doing so.
+ Preferences.reset(prefs.ENABLED_PREF);
+ await waitForStateTelemetry(["shutdown", "rollback"]);
+ await ensureTRRMode(undefined);
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoHeuristicsTelemetry();
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ await ensureNoHeuristicsTelemetry();
+
+ // Re-enable.
+ Preferences.set(prefs.ENABLED_PREF, true);
+
+ await ensureTRRMode(2);
+ ensureNoTRRSelectionTelemetry();
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(2);
+ await checkHeuristicsTelemetry("enable_doh", "netchange");
+
+ // Rollback again. This time, uninit DoHController first to ensure it reacts
+ // correctly at startup.
+ await DoHController._uninit();
+ await waitForStateTelemetry(["shutdown"]);
+ Preferences.reset(prefs.ENABLED_PREF);
+ await DoHController.init();
+ await ensureTRRMode(undefined);
+ ensureNoTRRSelectionTelemetry();
+ await ensureNoHeuristicsTelemetry();
+ await waitForStateTelemetry(["rollback"]);
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ await ensureNoHeuristicsTelemetry();
+});
diff --git a/browser/components/doh/test/browser/browser_trrMode_migration.js b/browser/components/doh/test/browser/browser_trrMode_migration.js
new file mode 100644
index 0000000000..e67dbe2fae
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_trrMode_migration.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testTRRModeMigration() {
+ // Test that previous TRR mode migration is correctly done - the dirtyEnable
+ // test verifies that the migration is not performed when unnecessary.
+ await DoHController._uninit();
+ setPassingHeuristics();
+ Preferences.set(prefs.NETWORK_TRR_MODE_PREF, 2);
+ Preferences.set(prefs.PREVIOUS_TRR_MODE_PREF, 0);
+ let modePromise = TestUtils.waitForPrefChange(prefs.NETWORK_TRR_MODE_PREF);
+ let previousModePromise = TestUtils.waitForPrefChange(
+ prefs.PREVIOUS_TRR_MODE_PREF
+ );
+ await DoHController.init();
+ await Promise.all([modePromise, previousModePromise]);
+
+ is(
+ Preferences.get(prefs.PREVIOUS_TRR_MODE_PREF),
+ undefined,
+ "Previous TRR mode pref cleared."
+ );
+ is(
+ Preferences.isSet(prefs.NETWORK_TRR_MODE_PREF),
+ false,
+ "TRR mode cleared."
+ );
+});
diff --git a/browser/components/doh/test/browser/browser_trrSelect.js b/browser/components/doh/test/browser/browser_trrSelect.js
new file mode 100644
index 0000000000..e7073b30e7
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_trrSelect.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testTRRSelect() {
+ // Set up the resolver lists in the default and user pref branches.
+ // dummyTRR3 which only exists in the user-branch value should be ignored.
+ let oldResolverList = Services.prefs.getCharPref("network.trr.resolvers");
+ Services.prefs
+ .getDefaultBranch("")
+ .setCharPref(
+ "network.trr.resolvers",
+ `[{"url": "https://dummytrr.com/query"}, {"url": "https://dummytrr2.com/query"}]`
+ );
+ Services.prefs.setCharPref(
+ "network.trr.resolvers",
+ `[{"url": "https://dummytrr.com/query"}, {"url": "https://dummytrr2.com/query"}, {"url": "https://dummytrr3.com/query"}]`
+ );
+
+ // Clean start: doh-rollout.uri should be set after init.
+ setPassingHeuristics();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF);
+ Preferences.set(prefs.ENABLED_PREF, true);
+ await prefPromise;
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete."
+ );
+
+ // Wait for heuristics to complete.
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ // Reset and restart the controller for good measure.
+ Preferences.reset(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF);
+ Preferences.reset(prefs.TRR_SELECT_URI_PREF);
+
+ prefPromise = TestUtils.waitForPrefChange(prefs.TRR_SELECT_URI_PREF);
+ await restartDoHController();
+ await prefPromise;
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete."
+ );
+
+ // Wait for heuristics to complete.
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ // Disable committing and reset. The committed URI should be cleared but the
+ // dry-run-result should persist.
+ Preferences.set(prefs.TRR_SELECT_COMMIT_PREF, false);
+ prefPromise = TestUtils.waitForPrefChange(prefs.TRR_SELECT_URI_PREF);
+ await restartDoHController();
+ await prefPromise;
+ ok(!Preferences.isSet(prefs.TRR_SELECT_URI_PREF), "TRR selection cleared.");
+ try {
+ await BrowserTestUtils.waitForCondition(() => {
+ return !Preferences.isSet(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF);
+ });
+ ok(false, "Dry run result was cleared, fail!");
+ } catch (e) {
+ ok(true, "Dry run result was not cleared.");
+ }
+ is(
+ Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF),
+ "https://dummytrr.com/query",
+ "dry-run result has the correct value."
+ );
+
+ // Wait for heuristics to complete.
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ // Reset and restart again, dry-run-result should be recorded but not
+ // be committed. Committing is still disabled from above.
+ Preferences.reset(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF);
+ Preferences.reset(prefs.TRR_SELECT_URI_PREF);
+ await restartDoHController();
+ try {
+ await BrowserTestUtils.waitForCondition(() => {
+ return Preferences.get(prefs.TRR_SELECT_URI_PREF);
+ });
+ ok(false, "Dry run result got committed, fail!");
+ } catch (e) {
+ ok(true, "Dry run result did not get committed");
+ }
+ is(
+ Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete, dry-run result recorded."
+ );
+ Preferences.set(prefs.TRR_SELECT_COMMIT_PREF, true);
+
+ // Wait for heuristics to complete.
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ // Reset doh-rollout.uri, and change the dry-run-result to another one on the
+ // default list. After init, the existing dry-run-result should be committed.
+ Preferences.reset(prefs.TRR_SELECT_URI_PREF);
+ Preferences.set(
+ prefs.TRR_SELECT_DRY_RUN_RESULT_PREF,
+ "https://dummytrr2.com/query"
+ );
+ prefPromise = TestUtils.waitForPrefChange(prefs.TRR_SELECT_URI_PREF);
+ await restartDoHController();
+ await prefPromise;
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr2.com/query",
+ "TRR selection complete, existing dry-run-result committed."
+ );
+
+ // Wait for heuristics to complete.
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ // Reset doh-rollout.uri, and change the dry-run-result to another one NOT on
+ // default list. After init, a new TRR should be selected and committed.
+ Preferences.reset(prefs.TRR_SELECT_URI_PREF);
+ Preferences.set(
+ prefs.TRR_SELECT_DRY_RUN_RESULT_PREF,
+ "https://dummytrr3.com/query"
+ );
+ prefPromise = TestUtils.waitForPrefChange(prefs.TRR_SELECT_URI_PREF);
+ await restartDoHController();
+ await prefPromise;
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete, existing dry-run-result discarded and refreshed."
+ );
+
+ // Wait for heuristics to complete.
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ Services.prefs
+ .getDefaultBranch("")
+ .setCharPref("network.trr.resolvers", oldResolverList);
+ Services.prefs.clearUserPref("network.trr.resolvers");
+});
diff --git a/browser/components/doh/test/browser/browser_trrSelection_disable.js b/browser/components/doh/test/browser/browser_trrSelection_disable.js
new file mode 100644
index 0000000000..d17ef1a218
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_trrSelection_disable.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testTrrSelectionDisable() {
+ // Set up a passing environment and enable DoH.
+ Preferences.set(prefs.TRR_SELECT_ENABLED_PREF, false);
+ setPassingHeuristics();
+ let promise = waitForDoorhanger();
+ Preferences.set(prefs.ENABLED_PREF, true);
+ await BrowserTestUtils.waitForCondition(() => {
+ return Preferences.get(prefs.BREADCRUMB_PREF);
+ });
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ is(
+ Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF),
+ undefined,
+ "TRR selection dry run not performed."
+ );
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ undefined,
+ "doh-rollout.uri remained unset."
+ );
+ ensureNoTRRSelectionTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL);
+ let panel = await promise;
+
+ // Click the doorhanger's "accept" button.
+ let button = panel.querySelector(".popup-notification-primary-button");
+ promise = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(button, {});
+ await promise;
+
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF);
+ });
+ is(
+ Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF),
+ "UIOk",
+ "Doorhanger decision saved."
+ );
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared.");
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Restart the controller for good measure.
+ await restartDoHController();
+ ensureNoTRRSelectionTelemetry();
+ is(
+ Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF),
+ undefined,
+ "TRR selection dry run not performed."
+ );
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ undefined,
+ "doh-rollout.uri remained unset."
+ );
+ await ensureNoTRRModeChange(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+});
diff --git a/browser/components/doh/test/browser/browser_userInterference.js b/browser/components/doh/test/browser/browser_userInterference.js
new file mode 100644
index 0000000000..c1c7e06fb9
--- /dev/null
+++ b/browser/components/doh/test/browser/browser_userInterference.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function testUserInterference() {
+ // Set up a passing environment and enable DoH.
+ setPassingHeuristics();
+ let promise = waitForDoorhanger();
+ let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF);
+ Preferences.set(prefs.ENABLED_PREF, true);
+
+ await prefPromise;
+ is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved.");
+ is(
+ Preferences.get(prefs.TRR_SELECT_URI_PREF),
+ "https://dummytrr.com/query",
+ "TRR selection complete."
+ );
+ await checkTRRSelectionTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL);
+ let panel = await promise;
+
+ prefPromise = TestUtils.waitForPrefChange(
+ prefs.DOORHANGER_USER_DECISION_PREF
+ );
+
+ // Click the doorhanger's "accept" button.
+ let button = panel.querySelector(".popup-notification-primary-button");
+ promise = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(button, {});
+ await promise;
+ await prefPromise;
+
+ is(
+ Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF),
+ "UIOk",
+ "Doorhanger decision saved."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ await ensureTRRMode(2);
+ await checkHeuristicsTelemetry("enable_doh", "startup");
+
+ // Set the TRR mode pref manually and ensure we respect this.
+ Preferences.set(prefs.NETWORK_TRR_MODE_PREF, 3);
+ await ensureTRRMode(undefined);
+
+ // Simulate a network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+
+ is(
+ Preferences.get(prefs.DISABLED_PREF, false),
+ true,
+ "Manual disable recorded."
+ );
+ is(Preferences.get(prefs.BREADCRUMB_PREF), undefined, "Breadcrumb cleared.");
+
+ // Simulate another network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+
+ // Restart the controller for good measure.
+ await restartDoHController();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoTRRSelectionTelemetry();
+ ensureNoHeuristicsTelemetry();
+
+ // Simulate another network change.
+ simulateNetworkChange();
+ await ensureNoTRRModeChange(undefined);
+ ensureNoHeuristicsTelemetry();
+});
diff --git a/browser/components/doh/test/browser/head.js b/browser/components/doh/test/browser/head.js
new file mode 100644
index 0000000000..f021d09eee
--- /dev/null
+++ b/browser/components/doh/test/browser/head.js
@@ -0,0 +1,312 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ASRouter",
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "DoHController",
+ "resource:///modules/DoHController.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gDNSService",
+ "@mozilla.org/network/dns-service;1",
+ "nsIDNSService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gDNSOverride",
+ "@mozilla.org/network/native-dns-override;1",
+ "nsINativeDNSResolverOverride"
+);
+
+const { CommonUtils } = ChromeUtils.import(
+ "resource://services-common/utils.js"
+);
+
+const EXAMPLE_URL = "https://example.com/";
+
+const prefs = {
+ ENABLED_PREF: "doh-rollout.enabled",
+ ROLLOUT_TRR_MODE_PREF: "doh-rollout.mode",
+ NETWORK_TRR_MODE_PREF: "network.trr.mode",
+ BREADCRUMB_PREF: "doh-rollout.self-enabled",
+ DOORHANGER_USER_DECISION_PREF: "doh-rollout.doorhanger-decision",
+ DISABLED_PREF: "doh-rollout.disable-heuristics",
+ SKIP_HEURISTICS_PREF: "doh-rollout.skipHeuristicsCheck",
+ CLEAR_ON_SHUTDOWN_PREF: "doh-rollout.clearModeOnShutdown",
+ FIRST_RUN_PREF: "doh-rollout.doneFirstRun",
+ BALROG_MIGRATION_PREF: "doh-rollout.balrog-migration-done",
+ PREVIOUS_TRR_MODE_PREF: "doh-rollout.previous.trr.mode",
+ TRR_SELECT_ENABLED_PREF: "doh-rollout.trr-selection.enabled",
+ TRR_SELECT_URI_PREF: "doh-rollout.uri",
+ TRR_SELECT_COMMIT_PREF: "doh-rollout.trr-selection.commit-result",
+ TRR_SELECT_DRY_RUN_RESULT_PREF: "doh-rollout.trr-selection.dry-run-result",
+ PROVIDER_STEERING_PREF: "doh-rollout.provider-steering.enabled",
+ PROVIDER_STEERING_LIST_PREF: "doh-rollout.provider-steering.provider-list",
+};
+
+const CFR_PREF = "browser.newtabpage.activity-stream.asrouter.providers.cfr";
+const CFR_JSON = {
+ id: "cfr",
+ enabled: true,
+ type: "local",
+ localProvider: "CFRMessageProvider",
+ categories: ["cfrAddons", "cfrFeatures"],
+};
+
+async function setup() {
+ SpecialPowers.pushPrefEnv({
+ set: [["security.notification_enable_delay", 0]],
+ });
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ Services.telemetry.clearEvents();
+
+ // Enable the CFR.
+ Preferences.set(CFR_PREF, JSON.stringify(CFR_JSON));
+
+ // Enable trr selection for tests. This is off by default so it can be
+ // controlled via Normandy.
+ Preferences.set(prefs.TRR_SELECT_ENABLED_PREF, true);
+
+ // Enable committing the TRR selection. This pref ships false by default so
+ // it can be controlled e.g. via Normandy, but for testing let's set enable.
+ Preferences.set(prefs.TRR_SELECT_COMMIT_PREF, true);
+
+ // Enable provider steering. This pref ships false by default so it can be
+ // controlled e.g. via Normandy, but for testing let's enable.
+ Preferences.set(prefs.PROVIDER_STEERING_PREF, true);
+
+ // Clear mode on shutdown by default.
+ Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, true);
+
+ // Set up heuristics, all passing by default.
+
+ // Google safesearch overrides
+ gDNSOverride.addIPOverride("www.google.com.", "1.1.1.1");
+ gDNSOverride.addIPOverride("google.com.", "1.1.1.1");
+ gDNSOverride.addIPOverride("forcesafesearch.google.com.", "1.1.1.2");
+
+ // YouTube safesearch overrides
+ gDNSOverride.addIPOverride("www.youtube.com.", "2.1.1.1");
+ gDNSOverride.addIPOverride("m.youtube.com.", "2.1.1.1");
+ gDNSOverride.addIPOverride("youtubei.googleapis.com.", "2.1.1.1");
+ gDNSOverride.addIPOverride("youtube.googleapis.com.", "2.1.1.1");
+ gDNSOverride.addIPOverride("www.youtube-nocookie.com.", "2.1.1.1");
+ gDNSOverride.addIPOverride("restrict.youtube.com.", "2.1.1.2");
+ gDNSOverride.addIPOverride("restrictmoderate.youtube.com.", "2.1.1.2");
+
+ // Zscaler override
+ gDNSOverride.addIPOverride("sitereview.zscaler.com.", "3.1.1.1");
+
+ // Global canary
+ gDNSOverride.addIPOverride("use-application-dns.net.", "4.1.1.1");
+
+ registerCleanupFunction(async () => {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ Services.telemetry.clearEvents();
+ gDNSOverride.clearOverrides();
+ if (ASRouter.state.messageBlockList.includes("DOH_ROLLOUT_CONFIRMATION")) {
+ await ASRouter.unblockMessageById("DOH_ROLLOUT_CONFIRMATION");
+ }
+ // The CFR pref is set to an empty array in user.js for testing profiles,
+ // so "reset" it back to that value.
+ Preferences.set(CFR_PREF, "[]");
+ await DoHController._uninit();
+ Services.telemetry.clearEvents();
+ Preferences.reset(Object.values(prefs));
+ await DoHController.init();
+ });
+}
+
+async function checkTRRSelectionTelemetry() {
+ let events;
+ await BrowserTestUtils.waitForCondition(() => {
+ events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent;
+ return events && events.length;
+ });
+ events = events.filter(
+ e =>
+ e[1] == "security.doh.trrPerformance" &&
+ e[2] == "trrselect" &&
+ e[3] == "dryrunresult"
+ );
+ is(events.length, 1, "Found the expected trrselect event.");
+ is(
+ events[0][4],
+ "https://dummytrr.com/query",
+ "The event records the expected decision"
+ );
+}
+
+function ensureNoTRRSelectionTelemetry() {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent;
+ if (!events) {
+ ok(true, "Found no trrselect events.");
+ return;
+ }
+ events = events.filter(
+ e =>
+ e[1] == "security.doh.trrPerformance" &&
+ e[2] == "trrselect" &&
+ e[3] == "dryrunresult"
+ );
+ is(events.length, 0, "Found no trrselect events.");
+}
+
+async function checkHeuristicsTelemetry(
+ decision,
+ evaluateReason,
+ steeredProvider = ""
+) {
+ let events;
+ await BrowserTestUtils.waitForCondition(() => {
+ events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent;
+ return events && events.length;
+ });
+ events = events.filter(
+ e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics"
+ );
+ is(events.length, 1, "Found the expected heuristics event.");
+ is(events[0][4], decision, "The event records the expected decision");
+ if (evaluateReason) {
+ is(events[0][5].evaluateReason, evaluateReason, "Got the expected reason.");
+ }
+ is(events[0][5].steeredProvider, steeredProvider, "Got expected provider.");
+
+ // After checking the event, clear all telemetry. Since we check for a single
+ // event above, this ensures all heuristics events are intentional and tested.
+ // TODO: Test events other than heuristics. Those tests would also work the
+ // same way, so as to test one event at a time, and this clearEvents() call
+ // will continue to exist as-is.
+ Services.telemetry.clearEvents();
+}
+
+function ensureNoHeuristicsTelemetry() {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent;
+ if (!events) {
+ ok(true, "Found no heuristics events.");
+ return;
+ }
+ events = events.filter(
+ e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics"
+ );
+ is(events.length, 0, "Found no heuristics events.");
+}
+
+async function waitForStateTelemetry(expectedStates) {
+ let events;
+ await BrowserTestUtils.waitForCondition(() => {
+ events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent;
+ return events;
+ });
+ events = events.filter(e => e[1] == "doh" && e[2] == "state");
+ is(events.length, expectedStates.length, "Found the expected state events.");
+ for (let state of expectedStates) {
+ let event = events.find(e => e[3] == state);
+ is(event[3], state, `${state} state found`);
+ }
+ Services.telemetry.clearEvents();
+}
+
+async function restartDoHController() {
+ let oldMode = Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF);
+ await DoHController._uninit();
+ let newMode = Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF);
+ let expectClear = Preferences.get(prefs.CLEAR_ON_SHUTDOWN_PREF);
+ is(
+ newMode,
+ expectClear ? undefined : oldMode,
+ `Mode was ${expectClear ? "cleared" : "persisted"} on shutdown.`
+ );
+ await DoHController.init();
+}
+
+// setPassing/FailingHeuristics are used generically to test that DoH is enabled
+// or disabled correctly. We use the zscaler canary arbitrarily here, individual
+// heuristics are tested separately.
+function setPassingHeuristics() {
+ gDNSOverride.clearHostOverride("sitereview.zscaler.com.");
+ gDNSOverride.addIPOverride("sitereview.zscaler.com.", "3.1.1.1");
+}
+
+function setFailingHeuristics() {
+ gDNSOverride.clearHostOverride("sitereview.zscaler.com.");
+ gDNSOverride.addIPOverride("sitereview.zscaler.com.", "213.152.228.242");
+}
+
+async function waitForDoorhanger() {
+ const popupID = "contextual-feature-recommendation";
+ const bucketID = "DOH_ROLLOUT_CONFIRMATION";
+ let panel;
+ await BrowserTestUtils.waitForEvent(document, "popupshown", true, event => {
+ panel = event.originalTarget;
+ let popupNotification = event.originalTarget.firstChild;
+ return (
+ popupNotification &&
+ popupNotification.notification &&
+ popupNotification.notification.id == popupID &&
+ popupNotification.getAttribute("data-notification-bucket") == bucketID
+ );
+ });
+ return panel;
+}
+
+function simulateNetworkChange() {
+ // The networkStatus API does not actually propagate the link status we supply
+ // here, but rather sends the link status from the NetworkLinkService.
+ // This means there's no point sending a down and then an up - the extension
+ // will just receive "up" twice.
+ // TODO: Implement a mock NetworkLinkService and use it to also simulate
+ // network down events.
+ Services.obs.notifyObservers(null, "network:link-status-changed", "up");
+}
+
+async function ensureTRRMode(mode) {
+ await BrowserTestUtils.waitForCondition(() => {
+ return Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF) === mode;
+ });
+ is(Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF), mode, `TRR mode is ${mode}`);
+}
+
+async function ensureNoTRRModeChange(mode) {
+ try {
+ // Try and wait for the TRR pref to change... waitForCondition should throw
+ // after trying for a while.
+ await BrowserTestUtils.waitForCondition(() => {
+ return Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF) !== mode;
+ });
+ // If we reach this, the waitForCondition didn't throw. Fail!
+ ok(false, "TRR mode changed when it shouldn't have!");
+ } catch (e) {
+ // Assert for clarity.
+ is(
+ Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF),
+ mode,
+ "No change in TRR mode"
+ );
+ }
+}
diff --git a/browser/components/doh/test/unit/head.js b/browser/components/doh/test/unit/head.js
new file mode 100644
index 0000000000..3108a0a0f6
--- /dev/null
+++ b/browser/components/doh/test/unit/head.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+const { PromiseUtils } = ChromeUtils.import(
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+let h2Port, trrServer1, trrServer2;
+let DNSLookup, LookupAggregator, TRRRacer;
+
+function readFile(file) {
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let data = NetUtil.readInputStreamToString(fstream, fstream.available());
+ fstream.close();
+ return data;
+}
+
+function addCertFromFile(certdb, filename, trustString) {
+ let certFile = do_get_file(filename, false);
+ let pem = readFile(certFile)
+ .replace(/-----BEGIN CERTIFICATE-----/, "")
+ .replace(/-----END CERTIFICATE-----/, "")
+ .replace(/[\r\n]/g, "");
+ certdb.addCertFromBase64(pem, trustString);
+}
+
+function ensureNoTelemetry() {
+ let events =
+ Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).parent || [];
+ events = events.filter(e => e[1] == "security.doh.trrPerformance");
+ Assert.ok(!events.length);
+}
+
+function setup() {
+ let env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ h2Port = env.get("MOZHTTP2_PORT");
+ Assert.notEqual(h2Port, null);
+ Assert.notEqual(h2Port, "");
+
+ // Set to allow the cert presented by our H2 server
+ do_get_profile();
+
+ Services.prefs.setBoolPref("network.http.spdy.enabled", true);
+ Services.prefs.setBoolPref("network.http.spdy.enabled.http2", true);
+
+ // use the h2 server as DOH provider
+ trrServer1 = `https://foo.example.com:${h2Port}/doh?responseIP=1.1.1.1`;
+ trrServer2 = `https://foo.example.com:${h2Port}/doh?responseIP=2.2.2.2`;
+ // make all native resolve calls "secretly" resolve localhost instead
+ Services.prefs.setBoolPref("network.dns.native-is-localhost", true);
+
+ // The moz-http2 cert is for foo.example.com and is signed by http2-ca.pem
+ // so add that cert to the trust list as a signing cert. // the foo.example.com domain name.
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+ addCertFromFile(certdb, "http2-ca.pem", "CTu,u,u");
+
+ Services.prefs.setIntPref("doh-rollout.trrRace.randomSubdomainCount", 2);
+
+ Services.prefs.setCharPref(
+ "doh-rollout.trrRace.popularDomains",
+ "foo.example.com., bar.example.com."
+ );
+
+ Services.prefs.setCharPref(
+ "doh-rollout.trrRace.canonicalDomain",
+ "firefox-dns-perf-test.net."
+ );
+
+ let defaultPrefBranch = Services.prefs.getDefaultBranch("");
+ let origResolverList = defaultPrefBranch.getCharPref("network.trr.resolvers");
+
+ Services.prefs
+ .getDefaultBranch("")
+ .setCharPref(
+ "network.trr.resolvers",
+ `[{"url": "${trrServer1}"}, {"url": "${trrServer2}"}]`
+ );
+
+ let TRRPerformance = ChromeUtils.import(
+ "resource:///modules/TRRPerformance.jsm"
+ );
+
+ DNSLookup = TRRPerformance.DNSLookup;
+ LookupAggregator = TRRPerformance.LookupAggregator;
+ TRRRacer = TRRPerformance.TRRRacer;
+
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("network.http.spdy.enabled");
+ Services.prefs.clearUserPref("network.http.spdy.enabled.http2");
+ Services.prefs.clearUserPref("network.dns.native-is-localhost");
+ defaultPrefBranch.setCharPref("network.trr.resolvers", origResolverList);
+
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+}
diff --git a/browser/components/doh/test/unit/test_DNSLookup.js b/browser/components/doh/test/unit/test_DNSLookup.js
new file mode 100644
index 0000000000..5951445f13
--- /dev/null
+++ b/browser/components/doh/test/unit/test_DNSLookup.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function test_SuccessfulRandomDNSLookup() {
+ let deferred = PromiseUtils.defer();
+ let lookup = new DNSLookup(
+ null,
+ trrServer1,
+ (request, record, status, usedDomain, retryCount) => {
+ deferred.resolve({ request, record, status, usedDomain, retryCount });
+ }
+ );
+ lookup.doLookup();
+ let result = await deferred.promise;
+ Assert.ok(result.usedDomain.endsWith(".firefox-dns-perf-test.net."));
+ Assert.equal(result.status, Cr.NS_OK);
+ Assert.ok(result.record.QueryInterface(Ci.nsIDNSAddrRecord));
+ Assert.ok(result.record.IsTRR());
+ Assert.greater(result.record.trrFetchDuration, 0);
+ Assert.equal(result.retryCount, 1);
+});
+
+add_task(async function test_SuccessfulSpecifiedDNSLookup() {
+ let deferred = PromiseUtils.defer();
+ let lookup = new DNSLookup(
+ "foo.example.com",
+ trrServer1,
+ (request, record, status, usedDomain, retryCount) => {
+ deferred.resolve({ request, record, status, usedDomain, retryCount });
+ }
+ );
+ lookup.doLookup();
+ let result = await deferred.promise;
+ Assert.equal(result.usedDomain, "foo.example.com");
+ Assert.equal(result.status, Cr.NS_OK);
+ Assert.ok(result.record.QueryInterface(Ci.nsIDNSAddrRecord));
+ Assert.ok(result.record.IsTRR());
+ Assert.greater(result.record.trrFetchDuration, 0);
+ Assert.equal(result.retryCount, 1);
+});
+
+add_task(async function test_FailedDNSLookup() {
+ let deferred = PromiseUtils.defer();
+ let lookup = new DNSLookup(
+ null,
+ `https://foo.example.com:${h2Port}/doh?responseIP=none`,
+ (request, record, status, usedDomain, retryCount) => {
+ deferred.resolve({ request, record, status, usedDomain, retryCount });
+ }
+ );
+ lookup.doLookup();
+ let result = await deferred.promise;
+ Assert.ok(result.usedDomain.endsWith(".firefox-dns-perf-test.net."));
+ Assert.notEqual(result.status, Cr.NS_OK);
+ Assert.equal(result.record, null);
+ Assert.equal(result.retryCount, 3);
+});
diff --git a/browser/components/doh/test/unit/test_LookupAggregator.js b/browser/components/doh/test/unit/test_LookupAggregator.js
new file mode 100644
index 0000000000..c5cb57645f
--- /dev/null
+++ b/browser/components/doh/test/unit/test_LookupAggregator.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm");
+
+add_task(setup);
+
+async function helper_SuccessfulLookupAggregator(
+ networkUnstable = false,
+ captivePortal = false
+) {
+ let deferred = PromiseUtils.defer();
+ let aggregator = new LookupAggregator(() => deferred.resolve());
+ // The aggregator's domain list should correctly reflect our set
+ // prefs for number of random subdomains (2) and the list of
+ // popular domains.
+ Assert.equal(aggregator.domains[0], null);
+ Assert.equal(aggregator.domains[1], null);
+ Assert.equal(aggregator.domains[2], "foo.example.com.");
+ Assert.equal(aggregator.domains[3], "bar.example.com.");
+ Assert.equal(aggregator.totalLookups, 8); // 2 TRRs * 4 domains.
+
+ if (networkUnstable) {
+ aggregator.markUnstableNetwork();
+ }
+ if (captivePortal) {
+ aggregator.markCaptivePortal();
+ }
+ aggregator.run();
+ await deferred.promise;
+ Assert.ok(!aggregator.aborted);
+ Assert.equal(aggregator.networkUnstable, networkUnstable);
+ Assert.equal(aggregator.captivePortal, captivePortal);
+ Assert.equal(aggregator.results.length, aggregator.totalLookups);
+
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).parent;
+ Assert.ok(events);
+ events = events.filter(e => e[1] == "security.doh.trrPerformance");
+ Assert.equal(events.length, aggregator.totalLookups);
+
+ for (let event of events) {
+ info(JSON.stringify(event));
+ Assert.equal(event[1], "security.doh.trrPerformance");
+ Assert.equal(event[2], "resolved");
+ Assert.equal(event[3], "record");
+ Assert.equal(event[4], "success");
+ }
+
+ // We only need to check the payload of each event from here on.
+ events = events.map(e => e[5]);
+
+ for (let trr of [trrServer1, trrServer2]) {
+ // There should be two results for random subdomains.
+ let results = aggregator.results.filter(r => {
+ return r.trr == trr && r.domain.endsWith(".firefox-dns-perf-test.net.");
+ });
+ Assert.equal(results.length, 2);
+
+ for (let result of results) {
+ Assert.ok(result.domain.endsWith(".firefox-dns-perf-test.net."));
+ Assert.equal(result.trr, trr);
+ Assert.ok(Components.isSuccessCode(result.status));
+ Assert.greater(result.time, 0);
+ Assert.equal(result.retryCount, 1);
+
+ let matchingEvents = events.filter(
+ e => e.domain == result.domain && e.trr == result.trr
+ );
+ Assert.equal(matchingEvents.length, 1);
+ let e = matchingEvents.pop();
+ for (let key of Object.keys(result)) {
+ Assert.equal(e[key], result[key].toString());
+ }
+ Assert.equal(e.networkUnstable, networkUnstable.toString());
+ Assert.equal(e.captivePortal, captivePortal.toString());
+ }
+
+ // There should be two results for the popular domains.
+ results = aggregator.results.filter(r => {
+ return r.trr == trr && !r.domain.endsWith(".firefox-dns-perf-test.net.");
+ });
+ Assert.equal(results.length, 2);
+
+ Assert.ok(
+ [results[0].domain, results[1].domain].includes("foo.example.com.")
+ );
+ Assert.ok(
+ [results[0].domain, results[1].domain].includes("bar.example.com.")
+ );
+ for (let result of results) {
+ Assert.equal(result.trr, trr);
+ Assert.equal(result.status, Cr.NS_OK);
+ Assert.greater(result.time, 0);
+ Assert.equal(result.retryCount, 1);
+
+ let matchingEvents = events.filter(
+ e => e.domain == result.domain && e.trr == result.trr
+ );
+ Assert.equal(matchingEvents.length, 1);
+ let e = matchingEvents.pop();
+ for (let key of Object.keys(result)) {
+ Assert.equal(e[key], result[key].toString());
+ }
+ Assert.equal(e.networkUnstable, networkUnstable.toString());
+ Assert.equal(e.captivePortal, captivePortal.toString());
+ }
+ }
+
+ Services.telemetry.clearEvents();
+}
+
+add_task(async function test_SuccessfulLookupAggregator() {
+ await helper_SuccessfulLookupAggregator(false, false);
+ await helper_SuccessfulLookupAggregator(false, true);
+ await helper_SuccessfulLookupAggregator(true, false);
+ await helper_SuccessfulLookupAggregator(true, true);
+});
+
+add_task(async function test_AbortedLookupAggregator() {
+ let deferred = PromiseUtils.defer();
+ let aggregator = new LookupAggregator(() => deferred.resolve());
+ // The aggregator's domain list should correctly reflect our set
+ // prefs for number of random subdomains (2) and the list of
+ // popular domains.
+ Assert.equal(aggregator.domains[0], null);
+ Assert.equal(aggregator.domains[1], null);
+ Assert.equal(aggregator.domains[2], "foo.example.com.");
+ Assert.equal(aggregator.domains[3], "bar.example.com.");
+ Assert.equal(aggregator.totalLookups, 8); // 2 TRRs * 4 domains.
+
+ // The aggregator should never call the onComplete callback. To test
+ // this, race the deferred promise with a 3 second timeout. The timeout
+ // should win, since the deferred promise should never resolve.
+ let timeoutPromise = new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => resolve("timeout"), 3000);
+ });
+ aggregator.run();
+ aggregator.abort();
+ let winner = await Promise.race([deferred.promise, timeoutPromise]);
+ Assert.equal(winner, "timeout");
+ Assert.ok(aggregator.aborted);
+ Assert.ok(!aggregator.networkUnstable);
+ Assert.ok(!aggregator.captivePortal);
+
+ // Ensure we send no telemetry for an aborted run!
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).parent;
+ Assert.ok(
+ !events || !events.filter(e => e[1] == "security.doh.trrPerformance").length
+ );
+});
diff --git a/browser/components/doh/test/unit/test_TRRRacer.js b/browser/components/doh/test/unit/test_TRRRacer.js
new file mode 100644
index 0000000000..ae3deae486
--- /dev/null
+++ b/browser/components/doh/test/unit/test_TRRRacer.js
@@ -0,0 +1,209 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+add_task(setup);
+
+add_task(async function test_TRRRacer_cleanRun() {
+ let deferred = PromiseUtils.defer();
+ let racer = new TRRRacer(() => {
+ deferred.resolve();
+ deferred.resolved = true;
+ });
+ racer.run();
+
+ await deferred.promise;
+ Assert.equal(racer._retryCount, 1);
+
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).parent;
+ Assert.ok(events);
+ events = events.filter(e => e[1] == "security.doh.trrPerformance");
+ Assert.equal(events.length, racer._aggregator.totalLookups);
+
+ Services.telemetry.clearEvents();
+
+ // Simulate network changes and ensure no re-runs since it's already complete.
+ async function testNetworkChange(captivePortal = false) {
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login");
+ } else {
+ Services.obs.notifyObservers(null, "network:link-status-changed", "down");
+ }
+
+ Assert.ok(!racer._aggregator.aborted);
+
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ } else {
+ Services.obs.notifyObservers(null, "network:link-status-changed", "up");
+ }
+
+ Assert.equal(racer._retryCount, 1);
+ ensureNoTelemetry();
+
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login-abort");
+ }
+ }
+
+ testNetworkChange(false);
+ testNetworkChange(true);
+});
+
+async function test_TRRRacer_networkFlux_helper(captivePortal = false) {
+ let deferred = PromiseUtils.defer();
+ let racer = new TRRRacer(() => {
+ deferred.resolve();
+ deferred.resolved = true;
+ });
+ racer.run();
+
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login");
+ } else {
+ Services.obs.notifyObservers(null, "network:link-status-changed", "down");
+ }
+
+ Assert.ok(racer._aggregator.aborted);
+ ensureNoTelemetry();
+ Assert.equal(racer._retryCount, 1);
+ Assert.ok(!deferred.resolved);
+
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ } else {
+ Services.obs.notifyObservers(null, "network:link-status-changed", "up");
+ }
+
+ Assert.ok(!racer._aggregator.aborted);
+ await deferred.promise;
+
+ Assert.equal(racer._retryCount, 2);
+
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).parent;
+ Assert.ok(events);
+ events = events.filter(e => e[1] == "security.doh.trrPerformance");
+ Assert.equal(events.length, racer._aggregator.totalLookups);
+
+ Services.telemetry.clearEvents();
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login-abort");
+ }
+}
+
+add_task(async function test_TRRRacer_networkFlux() {
+ await test_TRRRacer_networkFlux_helper(false);
+ await test_TRRRacer_networkFlux_helper(true);
+});
+
+async function test_TRRRacer_maxRetries_helper(captivePortal = false) {
+ let deferred = PromiseUtils.defer();
+ let racer = new TRRRacer(() => {
+ deferred.resolve();
+ deferred.resolved = true;
+ });
+ racer.run();
+ info("ran new racer");
+ // Start at i = 1 since we're already at retry #1.
+ for (let i = 1; i < 5; ++i) {
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login");
+ } else {
+ Services.obs.notifyObservers(null, "network:link-status-changed", "down");
+ }
+
+ info("notified observers");
+
+ Assert.ok(racer._aggregator.aborted);
+ ensureNoTelemetry();
+ Assert.equal(racer._retryCount, i);
+ Assert.ok(!deferred.resolved);
+
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ } else {
+ Services.obs.notifyObservers(null, "network:link-status-changed", "up");
+ }
+ }
+
+ // Simulate a "down" network event and ensure we still send telemetry
+ // since we've maxed out our retry count.
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login");
+ } else {
+ Services.obs.notifyObservers(null, "network:link-status-changed", "down");
+ }
+ Assert.ok(!racer._aggregator.aborted);
+ await deferred.promise;
+ Assert.equal(racer._retryCount, 5);
+
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).parent;
+ Assert.ok(events);
+ events = events.filter(e => e[1] == "security.doh.trrPerformance");
+ Assert.equal(events.length, racer._aggregator.totalLookups);
+
+ Services.telemetry.clearEvents();
+ if (captivePortal) {
+ Services.obs.notifyObservers(null, "captive-portal-login-abort");
+ }
+}
+
+add_task(async function test_TRRRacer_maxRetries() {
+ await test_TRRRacer_maxRetries_helper(false);
+ await test_TRRRacer_maxRetries_helper(true);
+});
+
+add_task(async function test_TRRRacer_getFastestTRRFromResults() {
+ let results = [
+ { trr: "trr1", time: 10 },
+ { trr: "trr2", time: 100 },
+ { trr: "trr1", time: 1000 },
+ { trr: "trr2", time: 110 },
+ { trr: "trr3", time: -1 },
+ { trr: "trr4", time: -1 },
+ { trr: "trr4", time: -1 },
+ { trr: "trr4", time: 1 },
+ { trr: "trr4", time: 1 },
+ { trr: "trr5", time: 10 },
+ { trr: "trr5", time: 20 },
+ { trr: "trr5", time: 1000 },
+ ];
+ let racer = new TRRRacer();
+ let fastest = racer._getFastestTRRFromResults(results);
+ // trr1's geometric mean is 100
+ // trr2's geometric mean is 110
+ // trr3 has no valid times, excluded
+ // trr4 has 50% invalid times, excluded
+ // trr5's geometric mean is ~58.5, it's the winner.
+ Assert.equal(fastest, "trr5");
+
+ // When no valid entries are available, undefined is the default output.
+ results = [
+ { trr: "trr1", time: -1 },
+ { trr: "trr2", time: -1 },
+ ];
+
+ fastest = racer._getFastestTRRFromResults(results);
+ Assert.equal(fastest, undefined);
+
+ // When passing `returnRandomDefault = true`, verify that both TRRs are
+ // possible outputs. The probability that the randomization is working
+ // correctly and we consistently get the same output after 50 iterations is
+ // 0.5^50 ~= 8.9*10^-16.
+ let firstResult = racer._getFastestTRRFromResults(results, true);
+ while (racer._getFastestTRRFromResults(results, true) == firstResult) {
+ continue;
+ }
+ Assert.ok(true, "Both TRRs were possible outputs when all results invalid.");
+});
diff --git a/browser/components/doh/test/unit/xpcshell.ini b/browser/components/doh/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..f86b521cae
--- /dev/null
+++ b/browser/components/doh/test/unit/xpcshell.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+head = head.js
+firefox-appdir = browser
+support-files =
+ ../../../../../netwerk/test/unit/http2-ca.pem
+
+[test_DNSLookup.js]
+skip-if = debug # Bug 1617845
+[test_LookupAggregator.js]
+[test_TRRRacer.js]