fune/browser/extensions/doh-rollout/background.js
Nihanth Subramanya 7745d09edf Bug 1631822 - Implement multiple TRR selection dry-run. r=valentin,johannh
This patch uses TRRPerformance.jsm to get the fastest TRR and store it in a pref
before running DoH heuristics. The chosen TRR URI is sent in a telemetry event.

Differential Revision: https://phabricator.services.mozilla.com/D72790
2020-05-01 20:05:15 +00:00

670 lines
21 KiB
JavaScript

/* 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";
/* global browser, runHeuristics */
let DEBUG;
async function log() {
// eslint-disable-next-line no-constant-condition
if (DEBUG) {
// eslint-disable-next-line no-console
console.log(...arguments);
}
}
// Gate-keeping pref to run the add-on
const DOH_ENABLED_PREF = "doh-rollout.enabled";
// When in automation, we parse this string pref as JSON and use it as our
// heuristics results set. This allows tests to set up different cases and
// verify the correct response.
const MOCK_HEURISTICS_PREF = "doh-rollout.heuristics.mockValues";
// Pref that sets DoH to on/off. It has multiple modes:
// 0: Off (default)
// 1: null (No setting)
// 2: Enabled, but will fall back to 0 on DNS lookup failure
// 3: Always on.
// 4: null (No setting)
// 5: Never on.
const TRR_MODE_PREF = "network.trr.mode";
// This preference is set to TRUE when DoH has been enabled via the add-on. It will
// allow the add-on to continue to function without the aid of the Normandy-triggered pref
// of "doh-rollout.enabled". Note that instead of setting it to false, it is cleared.
const DOH_SELF_ENABLED_PREF = "doh-rollout.self-enabled";
// This pref is part of a cache mechanism to see if the heuristics dictated a change in the DoH settings
const DOH_PREVIOUS_TRR_MODE_PREF = "doh-rollout.previous.trr.mode";
// Set after doorhanger has been interacted with by the user
const DOH_DOORHANGER_SHOWN_PREF = "doh-rollout.doorhanger-shown";
// Records if the user opted in/out of DoH study by clicking on doorhanger
const DOH_DOORHANGER_USER_DECISION_PREF = "doh-rollout.doorhanger-decision";
// Records if user has decided to opt out of study, either by disabling via the doorhanger,
// unchecking "DNS-over-HTTPS" with about:preferences, or manually setting network.trr.mode
const DOH_DISABLED_PREF = "doh-rollout.disable-heuristics";
// Set to true when a user has ANY enterprise policy set, making sure to not run
// heuristics, overwritting the policy.
const DOH_SKIP_HEURISTICS_PREF = "doh-rollout.skipHeuristicsCheck";
// Records when the add-on has been run once. This is in place to only check
// network.trr.mode for prefHasUserValue on first run.
const DOH_DONE_FIRST_RUN_PREF = "doh-rollout.doneFirstRun";
// This pref is set once a migration function has ran, updating local storage items to the
// new doh-rollot.X namespace. This applies to both `doneFirstRun` and `skipHeuristicsCheck`.
const DOH_BALROG_MIGRATION_PREF = "doh-rollout.balrog-migration-done";
// If set to true, debug logging will be enabled.
const DOH_DEBUG_PREF = "doh-rollout.debug";
const stateManager = {
async setState(state) {
log("setState: ", state);
switch (state) {
case "uninstalled":
break;
case "disabled":
await rollout.setSetting(TRR_MODE_PREF, 0);
break;
case "manuallyDisabled":
await browser.experiments.preferences.clearUserPref(
DOH_SELF_ENABLED_PREF
);
break;
case "UIOk":
await rollout.setSetting(DOH_SELF_ENABLED_PREF, true);
break;
case "enabled":
await rollout.setSetting(TRR_MODE_PREF, 2);
await rollout.setSetting(DOH_SELF_ENABLED_PREF, true);
break;
case "UIDisabled":
await rollout.setSetting(TRR_MODE_PREF, 5);
await browser.experiments.preferences.clearUserPref(
DOH_SELF_ENABLED_PREF
);
break;
}
await browser.experiments.heuristics.sendStatePing(state);
await stateManager.rememberTRRMode();
},
async rememberTRRMode() {
let curMode = await browser.experiments.preferences.getIntPref(
TRR_MODE_PREF,
0
);
log("Saving current trr mode:", curMode);
await rollout.setSetting(DOH_PREVIOUS_TRR_MODE_PREF, curMode, true);
},
async rememberDoorhangerShown() {
// This will be shown on startup and network changes until a user clicks
// to confirm/disable DoH or presses the esc key (confirming)
log("Remembering that doorhanger has been shown");
await rollout.setSetting(DOH_DOORHANGER_SHOWN_PREF, true);
},
async rememberDoorhangerDecision(decision) {
log("Remember doorhanger decision:", decision);
await rollout.setSetting(DOH_DOORHANGER_USER_DECISION_PREF, decision, true);
},
async rememberDisableHeuristics() {
log("Remembering to never run heuristics again");
await rollout.setSetting(DOH_DISABLED_PREF, true);
},
async shouldRunHeuristics() {
// Check if heuristics has been disabled from rememberDisableHeuristics()
let disableHeuristics = await rollout.getSetting(DOH_DISABLED_PREF, false);
let skipHeuristicsCheck = await rollout.getSetting(
DOH_SKIP_HEURISTICS_PREF,
false
);
if (disableHeuristics || skipHeuristicsCheck) {
// Do not modify DoH for this user.
log("shouldRunHeuristics: Will not run heuristics");
return false;
}
let prevMode = await rollout.getSetting(DOH_PREVIOUS_TRR_MODE_PREF, 0);
let curMode = await browser.experiments.preferences.getIntPref(
TRR_MODE_PREF,
0
);
log("Comparing previous trr mode to current mode:", prevMode, curMode);
// Don't run heuristics if:
// 1) Previous doesn't mode equals current mode, i.e. user overrode our changes
// 2) TRR mode equals 5, i.e. user clicked "No" on doorhanger
// 3) TRR mode equals 3, i.e. user enabled "strictly on" for DoH
// 4) They've been disabled in the past for the reasons listed above
//
// In other words, if the user has made their own decision for DoH,
// then we want to respect that and never run the heuristics again
if (prevMode === curMode) {
return true;
}
// On Mismatch - run never run again (make init check a function)
log("Mismatched, curMode: ", curMode);
// Cache results for Telemetry send, including setting eval reason
let results = await runHeuristics();
results.evaluateReason = "userModified";
if (curMode === 0 || curMode === 5) {
// If user has manually set trr.mode to 0, and it was previously something else.
browser.experiments.heuristics.sendHeuristicsPing("disable_doh", results);
browser.experiments.preferences.clearUserPref(DOH_SELF_ENABLED_PREF);
await stateManager.rememberDisableHeuristics();
} else {
// Check if trr.mode is not in default value.
await rollout.trrModePrefHasUserValue(
"shouldRunHeuristics_mismatch",
results
);
}
return false;
},
async shouldShowDoorhanger() {
let doorhangerShown = await rollout.getSetting(
DOH_DOORHANGER_SHOWN_PREF,
false
);
log("Should show doorhanger:", !doorhangerShown);
return !doorhangerShown;
},
async showDoorhanger() {
rollout.addDoorhangerListeners();
let doorhangerShown = await browser.experiments.doorhanger.show({
name: browser.i18n.getMessage("doorhangerName"),
text: "<> " + browser.i18n.getMessage("doorhangerBodyNew"),
okLabel: browser.i18n.getMessage("doorhangerButtonOk"),
okAccessKey: browser.i18n.getMessage("doorhangerButtonOkAccessKey"),
cancelLabel: browser.i18n.getMessage("doorhangerButtonCancel2"),
cancelAccessKey: browser.i18n.getMessage(
"doorhangerButtonCancelAccessKey"
),
});
if (!doorhangerShown) {
// The profile was created after the go-live date of the privacy statement
// that included DoH. Treat it as accepted.
log("Profile is new, doorhanger not shown.");
await stateManager.setState("UIOk");
await stateManager.rememberDoorhangerDecision("NewProfile");
await stateManager.rememberDoorhangerShown();
rollout.removeDoorhangerListeners();
}
},
};
const rollout = {
// Pretend that there was a network change at the beginning of time.
lastNetworkChangeTime: 0,
async isTesting() {
if (this._isTesting === undefined) {
this._isTesting = await browser.experiments.heuristics.isTesting();
}
return this._isTesting;
},
addDoorhangerListeners() {
browser.experiments.doorhanger.onDoorhangerAccept.addListener(
rollout.doorhangerAcceptListener
);
browser.experiments.doorhanger.onDoorhangerDecline.addListener(
rollout.doorhangerDeclineListener
);
},
removeDoorhangerListeners() {
browser.experiments.doorhanger.onDoorhangerAccept.removeListener(
rollout.doorhangerAcceptListener
);
browser.experiments.doorhanger.onDoorhangerDecline.removeListener(
rollout.doorhangerDeclineListener
);
},
async doorhangerAcceptListener(tabId) {
log("Doorhanger accepted on tab", tabId);
await stateManager.setState("UIOk");
await stateManager.rememberDoorhangerDecision("UIOk");
await stateManager.rememberDoorhangerShown();
rollout.removeDoorhangerListeners();
},
async doorhangerDeclineListener(tabId) {
log("Doorhanger declined on tab", tabId);
await stateManager.setState("UIDisabled");
await stateManager.rememberDoorhangerDecision("UIDisabled");
let results = await runHeuristics();
results.evaluateReason = "doorhangerDecline";
browser.experiments.heuristics.sendHeuristicsPing("disable_doh", results);
await stateManager.rememberDisableHeuristics();
await stateManager.rememberDoorhangerShown();
rollout.removeDoorhangerListeners();
},
async heuristics(evaluateReason) {
let shouldRunHeuristics = await stateManager.shouldRunHeuristics();
if (!shouldRunHeuristics) {
return;
}
// Run heuristics defined in heuristics.js and experiments/heuristics/api.js
let results;
if (await rollout.isTesting()) {
results = await browser.experiments.preferences.getCharPref(
MOCK_HEURISTICS_PREF,
`{ "test": "disable_doh" }`
);
results = JSON.parse(results);
} else {
results = await runHeuristics();
}
// Check if DoH should be disabled
let decision = Object.values(results).includes("disable_doh")
? "disable_doh"
: "enable_doh";
log("Heuristics decision on " + evaluateReason + ": " + decision);
// Send Telemetry on results of heuristics
results.evaluateReason = evaluateReason;
browser.experiments.heuristics.sendHeuristicsPing(decision, results);
if (decision === "disable_doh") {
await stateManager.setState("disabled");
} else {
await stateManager.setState("enabled");
if (await stateManager.shouldShowDoorhanger()) {
await stateManager.showDoorhanger();
}
}
},
async getSetting(name, defaultValue) {
let value;
switch (typeof defaultValue) {
case "boolean":
value = await browser.experiments.preferences.getBoolPref(
name,
defaultValue
);
break;
case "number":
value = await browser.experiments.preferences.getIntPref(
name,
defaultValue
);
break;
case "string":
value = await browser.experiments.preferences.getCharPref(
name,
defaultValue
);
break;
default:
throw new Error(
`Invalid defaultValue argument when trying to fetch pref: ${JSON.stringify(
name
)}`
);
}
log({
context: "getSetting",
type: typeof defaultValue,
name,
value,
});
return value;
},
/**
* Exposed
*
* @param {type} name description
* @param {type} value description
* @return {type} description
*/
async setSetting(name, value) {
// Based on type of pref, set pref accordingly
switch (typeof value) {
case "boolean":
await browser.experiments.preferences.setBoolPref(name, value);
break;
case "number":
await browser.experiments.preferences.setIntPref(name, value);
break;
case "string":
await browser.experiments.preferences.setCharPref(name, value);
break;
default:
throw new Error("setSetting typeof value unknown!");
}
log({
context: "setSetting",
type: typeof value,
name,
value,
});
},
async trrModePrefHasUserValue(event, results) {
results.evaluateReason = event;
// This confirms if a user has modified DoH (via the TRR_MODE_PREF) outside of the addon
// This runs only on the FIRST time that add-on is enabled and if the stored pref
// mismatches the current pref (Meaning something outside of the add-on has changed it)
if (await browser.experiments.preferences.prefHasUserValue(TRR_MODE_PREF)) {
// Send ping that user had specific trr.mode pref set before add-on study was ran.
// Note that this does not include the trr.mode - just that the addon cannot be ran.
browser.experiments.heuristics.sendHeuristicsPing(
"prefHasUserValue",
results
);
browser.experiments.preferences.clearUserPref(DOH_SELF_ENABLED_PREF);
await this.setSetting(DOH_SKIP_HEURISTICS_PREF, true);
await stateManager.rememberDisableHeuristics();
}
},
async enterprisePolicyCheck(event, results) {
results.evaluateReason = event;
// Check if trrModePrefHasUserValue determined to not enable add-on on first run
let skipHeuristicsCheck = await rollout.getSetting(
DOH_SKIP_HEURISTICS_PREF,
false
);
if (skipHeuristicsCheck) {
return;
}
// Check for Policies before running the rest of the heuristics
let policyEnableDoH = await browser.experiments.heuristics.checkEnterprisePolicies();
log("Enterprise Policy Check:", policyEnableDoH);
// Determine to skip additional heuristics (by presence of an enterprise policy)
if (policyEnableDoH === "no_policy_set") {
// Resetting skipHeuristicsCheck in case a user had a policy and then removed it!
await this.setSetting(DOH_SKIP_HEURISTICS_PREF, false);
return;
}
if (policyEnableDoH === "policy_without_doh") {
await stateManager.setState("disabled");
}
// Don't check for prefHasUserValue if policy is set to disable DoH
await this.setSetting(DOH_SKIP_HEURISTICS_PREF, true);
browser.experiments.heuristics.sendHeuristicsPing(policyEnableDoH, results);
},
async migrateLocalStoragePrefs() {
// Migrate updated local storage item names. If this has already been done once, skip the migration
const isMigrated = await browser.experiments.preferences.getBoolPref(
DOH_BALROG_MIGRATION_PREF,
false
);
if (isMigrated) {
log("User has already been migrated.");
return;
}
// Check all local storage keys from v1.0.4 users and migrate them to prefs.
// This only applies to keys that have a value.
const legacyLocalStorageKeys = [
"doneFirstRun",
"skipHeuristicsCheck",
DOH_PREVIOUS_TRR_MODE_PREF,
DOH_DOORHANGER_SHOWN_PREF,
DOH_DOORHANGER_USER_DECISION_PREF,
DOH_DISABLED_PREF,
];
for (let item of legacyLocalStorageKeys) {
let data = await browser.storage.local.get(item);
let value = data[item];
log({ context: "migration", item, value });
if (data.hasOwnProperty(item)) {
let migratedName = item;
if (!item.startsWith("doh-rollout.")) {
migratedName = "doh-rollout." + item;
}
await this.setSetting(migratedName, value);
}
}
// Set pref to skip this function in the future.
browser.experiments.preferences.setBoolPref(
DOH_BALROG_MIGRATION_PREF,
true
);
log("User successfully migrated.");
},
async init() {
log("calling init");
// Check if the add-on has run before
let doneFirstRun = await this.getSetting(DOH_DONE_FIRST_RUN_PREF, false);
// Register the events for sending pings
browser.experiments.heuristics.setupTelemetry();
// Cache runHeuristics results for first run/start up checks
let results = await runHeuristics();
if (!doneFirstRun) {
log("first run!");
await this.setSetting(DOH_DONE_FIRST_RUN_PREF, true);
// Check if user has a set a custom pref only on first run, not on each startup
await this.trrModePrefHasUserValue("first_run", results);
await this.enterprisePolicyCheck("first_run", results);
} else {
log("not first run!");
await this.enterprisePolicyCheck("startup", results);
}
if (!(await stateManager.shouldRunHeuristics())) {
return;
}
// Perform TRR selection before running heuristics.
await browser.experiments.trrselect.dryRun();
log("TRR selection dry run complete!");
let networkStatus = (await browser.networkStatus.getLinkInfo()).status;
let captiveState = "unknown";
try {
captiveState = await browser.captivePortal.getState();
} catch (e) {
// Captive Portal Service is disabled.
}
if (networkStatus == "up" && captiveState != "locked_portal") {
await rollout.heuristics("startup");
}
// Listen for network change events to run heuristics again
browser.networkStatus.onConnectionChanged.addListener(
rollout.onConnectionChanged
);
// Listen to the captive portal when it unlocks
try {
browser.captivePortal.onStateChange.addListener(
rollout.onCaptiveStateChanged
);
} catch (e) {
// Captive Portal Service is disabled.
}
},
async onConnectionChanged({ status }) {
log("onConnectionChanged", status);
if (status != "up") {
return;
}
let captiveState = "unknown";
try {
captiveState = await browser.captivePortal.getState();
} catch (e) {
// Captive Portal Service is disabled.
}
if (captiveState == "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 rollout.heuristics("netchange");
},
async onCaptiveStateChanged({ state }) {
log("onCaptiveStateChanged", state);
// unlocked_portal means we were previously in a locked portal and then
// network access was granted.
if (state == "unlocked_portal") {
await rollout.heuristics("netchange");
}
},
};
const setup = {
async start() {
const isAddonDisabled = await rollout.getSetting(DOH_DISABLED_PREF, false);
const runAddonPref = await rollout.getSetting(DOH_ENABLED_PREF, false);
const runAddonBypassPref = await rollout.getSetting(
DOH_SELF_ENABLED_PREF,
false
);
const runAddonDoorhangerDecision = await rollout.getSetting(
DOH_DOORHANGER_USER_DECISION_PREF,
""
);
const runAddonPreviousTRRMode = await rollout.getSetting(
DOH_PREVIOUS_TRR_MODE_PREF,
-1
);
if (isAddonDisabled) {
// Regardless of pref, the user has chosen/heuristics dictated that this add-on should be disabled.
// DoH status will not be modified from whatever the current setting is at runtime
log(
"Addon has been disabled. DoH status will not be modified from current setting"
);
await stateManager.rememberDisableHeuristics();
return;
}
if (
runAddonPref ||
runAddonBypassPref ||
runAddonDoorhangerDecision === "UIOk" ||
runAddonDoorhangerDecision === "enabled" ||
runAddonPreviousTRRMode === 2 ||
runAddonPreviousTRRMode === 0
) {
rollout.init();
} else {
log("Disabled, aborting!");
}
},
};
(async () => {
DEBUG = await browser.experiments.preferences.getBoolPref(
DOH_DEBUG_PREF,
false
);
// Run Migration First, to continue to run rest of start up logic
await rollout.migrateLocalStoragePrefs();
log("Watching `doh-rollout.enabled` pref");
browser.experiments.preferences.onPrefChanged.addListener(async () => {
let enabled = await rollout.getSetting(DOH_ENABLED_PREF, false);
if (enabled) {
setup.start();
} else {
// Reset the TRR mode if we were running normally with no user-interference.
if (await stateManager.shouldRunHeuristics()) {
await stateManager.setState("disabled");
}
// Remove our listeners.
browser.networkStatus.onConnectionChanged.removeListener(
rollout.onConnectionChanged
);
try {
browser.captivePortal.onStateChange.removeListener(
rollout.onCaptiveStateChanged
);
} catch (e) {
// Captive Portal Service is disabled.
}
}
});
if (await rollout.getSetting(DOH_ENABLED_PREF, false)) {
await setup.start();
} else if (
(await rollout.getSetting(DOH_DONE_FIRST_RUN_PREF, false)) &&
(await stateManager.shouldRunHeuristics())
) {
// We previously had turned on DoH, and now after a restart we've been
// rolled back. Reset TRR mode.
await stateManager.setState("disabled");
}
})();