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