/* 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"; const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); const { LogManager } = ChromeUtils.import( "resource://normandy/lib/LogManager.jsm" ); XPCOMUtils.defineLazyServiceGetter( this, "timerManager", "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager" ); XPCOMUtils.defineLazyModuleGetters(this, { RemoteSettings: "resource://services-settings/remote-settings.js", Storage: "resource://normandy/lib/Storage.jsm", FilterExpressions: "resource://gre/modules/components-utils/FilterExpressions.jsm", TargetingContext: "resource://messaging-system/targeting/Targeting.jsm", NormandyApi: "resource://normandy/lib/NormandyApi.jsm", ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm", CleanupManager: "resource://normandy/lib/CleanupManager.jsm", Uptake: "resource://normandy/lib/Uptake.jsm", ActionsManager: "resource://normandy/lib/ActionsManager.jsm", BaseAction: "resource://normandy/actions/BaseAction.jsm", RemoteSettingsClient: "resource://services-settings/RemoteSettingsClient.jsm", clearTimeout: "resource://gre/modules/Timer.jsm", setTimeout: "resource://gre/modules/Timer.jsm", }); var EXPORTED_SYMBOLS = ["RecipeRunner"]; const log = LogManager.getLogger("recipe-runner"); const TIMER_NAME = "recipe-client-addon-run"; const REMOTE_SETTINGS_COLLECTION = "normandy-recipes-capabilities"; const PREF_CHANGED_TOPIC = "nsPref:changed"; const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds"; const FIRST_RUN_PREF = "app.normandy.first_run"; const SHIELD_ENABLED_PREF = "app.normandy.enabled"; const DEV_MODE_PREF = "app.normandy.dev_mode"; const API_URL_PREF = "app.normandy.api_url"; const LAZY_CLASSIFY_PREF = "app.normandy.experiments.lazy_classify"; const ONSYNC_SKEW_SEC_PREF = "app.normandy.onsync_skew_sec"; // Timer last update preference. // see https://searchfox.org/mozilla-central/rev/11cfa0462/toolkit/components/timermanager/UpdateTimerManager.jsm#8 const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`; const PREFS_TO_WATCH = [RUN_INTERVAL_PREF, SHIELD_ENABLED_PREF, API_URL_PREF]; XPCOMUtils.defineLazyGetter(this, "gRemoteSettingsClient", () => { return RemoteSettings(REMOTE_SETTINGS_COLLECTION); }); /** * cacheProxy returns an object Proxy that will memoize properties of the target. */ function cacheProxy(target) { const cache = new Map(); return new Proxy(target, { get(target, prop, receiver) { if (!cache.has(prop)) { cache.set(prop, target[prop]); } return cache.get(prop); }, set(target, prop, value, receiver) { cache.set(prop, value); return true; }, has(target, prop) { return cache.has(prop) || prop in target; }, }); } var RecipeRunner = { async init() { this.running = false; this.enabled = null; this.loadFromRemoteSettings = false; this._syncSkewTimeout = null; this.checkPrefs(); // sets this.enabled this.watchPrefs(); this.setUpRemoteSettings(); // Here "first run" means the first run this profile has ever done. This // preference is set to true at the end of this function, and never reset to // false. const firstRun = Services.prefs.getBoolPref(FIRST_RUN_PREF, true); // If we've seen a build ID from a previous run that doesn't match the // current build ID, run immediately. This is probably an upgrade or // downgrade, which may cause recipe eligibility to change. let hasNewBuildID = Services.appinfo.lastAppBuildID != null && Services.appinfo.lastAppBuildID != Services.appinfo.appBuildID; // Dev mode is a mode used for development and QA that bypasses the normal // timer function of Normandy, to make testing more convenient. const devMode = Services.prefs.getBoolPref(DEV_MODE_PREF, false); if (this.enabled && (devMode || firstRun || hasNewBuildID)) { // In dev mode, if remote settings is enabled, force an immediate sync // before running. This ensures that the latest data is used for testing. // This is not needed for the first run case, because remote settings // already handles empty collections well. if (devMode) { await gRemoteSettingsClient.sync(); } let trigger; if (devMode) { trigger = "devMode"; } else if (firstRun) { trigger = "firstRun"; } else if (hasNewBuildID) { trigger = "newBuildID"; } await this.run({ trigger }); } // Update the firstRun pref, to indicate that Normandy has run at least once // on this profile. if (firstRun) { Services.prefs.setBoolPref(FIRST_RUN_PREF, false); } }, enable() { if (this.enabled) { return; } this.registerTimer(); this.enabled = true; }, disable() { if (this.enabled) { this.unregisterTimer(); } // this.enabled may be null, so always set it to false this.enabled = false; }, /** Watch for prefs to change, and call this.observer when they do */ watchPrefs() { for (const pref of PREFS_TO_WATCH) { Services.prefs.addObserver(pref, this); } CleanupManager.addCleanupHandler(this.unwatchPrefs.bind(this)); }, unwatchPrefs() { for (const pref of PREFS_TO_WATCH) { Services.prefs.removeObserver(pref, this); } }, /** When prefs change, this is fired */ observe(subject, topic, data) { switch (topic) { case PREF_CHANGED_TOPIC: { const prefName = data; switch (prefName) { case RUN_INTERVAL_PREF: this.updateRunInterval(); break; // explicit fall-through case SHIELD_ENABLED_PREF: case API_URL_PREF: this.checkPrefs(); break; default: log.debug( `Observer fired with unexpected pref change: ${prefName}` ); } break; } } }, checkPrefs() { if (!Services.prefs.getBoolPref(SHIELD_ENABLED_PREF)) { log.debug( `Disabling Shield because ${SHIELD_ENABLED_PREF} is set to false` ); this.disable(); return; } const apiUrl = Services.prefs.getCharPref(API_URL_PREF); if (!apiUrl) { log.warn(`Disabling Shield because ${API_URL_PREF} is not set.`); this.disable(); return; } if (!apiUrl.startsWith("https://")) { log.warn( `Disabling Shield because ${API_URL_PREF} is not an HTTPS url: ${apiUrl}.` ); this.disable(); return; } log.debug(`Enabling Shield`); this.enable(); }, registerTimer() { this.updateRunInterval(); CleanupManager.addCleanupHandler(() => timerManager.unregisterTimer(TIMER_NAME) ); }, unregisterTimer() { timerManager.unregisterTimer(TIMER_NAME); }, setUpRemoteSettings() { if (this._alreadySetUpRemoteSettings) { return; } this._alreadySetUpRemoteSettings = true; if (!this._onSync) { this._onSync = this.onSync.bind(this); } gRemoteSettingsClient.on("sync", this._onSync); CleanupManager.addCleanupHandler(() => { gRemoteSettingsClient.off("sync", this._onSync); this._alreadySetUpRemoteSettings = false; }); }, /** Called when our Remote Settings collection is updated */ async onSync() { if (!this.enabled) { return; } // Delay the Normandy run by a random amount, determined by preference. // This helps alleviate server load, since we don't have a thundering // herd of users trying to update all at once. if (this._syncSkewTimeout) { clearTimeout(this._syncSkewTimeout); } let minSkewSec = 1; // this is primarily is to avoid race conditions in tests let maxSkewSec = Services.prefs.getIntPref(ONSYNC_SKEW_SEC_PREF, 0); if (maxSkewSec >= minSkewSec) { let skewMillis = (minSkewSec + Math.random() * (maxSkewSec - minSkewSec)) * 1000; log.debug( `Delaying on-sync Normandy run for ${Math.floor( skewMillis / 1000 )} seconds` ); this._syncSkewTimeout = setTimeout( () => this.run({ trigger: "sync" }), skewMillis ); } else { log.debug(`Not skewing on-sync Normandy run`); await this.run({ trigger: "sync" }); } }, updateRunInterval() { // Run once every `runInterval` wall-clock seconds. This is managed by setting a "last ran" // timestamp, and running if it is more than `runInterval` seconds ago. Even with very short // intervals, the timer will only fire at most once every few minutes. const runInterval = Services.prefs.getIntPref(RUN_INTERVAL_PREF); timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval); }, async run({ trigger = "timer" } = {}) { if (this.running) { // Do nothing if already running. return; } try { this.running = true; Services.obs.notifyObservers(null, "recipe-runner:start"); if (this._syncSkewTimeout) { clearTimeout(this._syncSkewTimeout); this._syncSkewTimeout = null; } this.clearCaches(); // Unless lazy classification is enabled, prep the classify cache. if (!Services.prefs.getBoolPref(LAZY_CLASSIFY_PREF, false)) { try { await ClientEnvironment.getClientClassification(); } catch (err) { // Try to go on without this data; the filter expressions will // gracefully fail without this info if they need it. } } // Fetch recipes before execution in case we fail and exit early. let recipesAndSignatures; try { recipesAndSignatures = await gRemoteSettingsClient.get(); } catch (e) { await Uptake.reportRunner(Uptake.RUNNER_SERVER_ERROR); return; } const actionsManager = new ActionsManager(); // Execute recipes, if we have any. if (recipesAndSignatures.length === 0) { log.debug("No recipes to execute"); } else { for (const { recipe, signature } of recipesAndSignatures) { let suitability = await this.getRecipeSuitability(recipe, signature); await actionsManager.processRecipe(recipe, suitability); } } await actionsManager.finalize(); await Uptake.reportRunner(Uptake.RUNNER_SUCCESS); Services.obs.notifyObservers(null, "recipe-runner:end"); } finally { this.running = false; if (trigger != "timer") { // `run()` was executed outside the scheduled timer. // Update the last time it ran to make sure it is rescheduled later. const lastUpdateTime = Math.round(Date.now() / 1000); Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime); } } }, getFilterContext(recipe) { const environment = cacheProxy(ClientEnvironment); environment.recipe = { id: recipe.id, arguments: recipe.arguments, }; return { env: environment, // Backwards compatibility -- see bug 1477255. normandy: environment, }; }, /** * Return the set of capabilities this runner has. * * This is used to pre-filter recipes that aren't compatible with this client. * * @returns {Set} The capabilities supported by this client. */ getCapabilities() { let capabilities = new Set([ "capabilities-v1", // The initial version of the capabilities system. ]); // Get capabilities from ActionsManager. for (const actionCapability of ActionsManager.getCapabilities()) { capabilities.add(actionCapability); } // Add a capability for each transform available to JEXL. for (const transform of FilterExpressions.getAvailableTransforms()) { capabilities.add(`jexl.transform.${transform}`); } // Add two capabilities for each top level key available in the context: one // for the `normandy.` namespace, and another for the `env.` namespace. capabilities.add("jexl.context.env"); capabilities.add("jexl.context.normandy"); let env = ClientEnvironment; while (env && env.name) { // Walk up the class chain for ClientEnvironment, collecting applicable // properties as we go. Stop when we get to an unnamed object, which is // usually just a plain function is the super class of a class that doesn't // extend anything. Also stop if we get to an undefined object, just in // case. for (const [name, descriptor] of Object.entries( Object.getOwnPropertyDescriptors(env) )) { // All of the properties we are looking for are are static getters (so // will have a truthy `get` property) and are defined on the class, so // will be configurable if (descriptor.configurable && descriptor.get) { capabilities.add(`jexl.context.env.${name}`); capabilities.add(`jexl.context.normandy.${name}`); } } // Check for the next parent env = Object.getPrototypeOf(env); } return capabilities; }, /** * Decide if a recipe is suitable to run, and returns a value from * `BaseAction.suitability`. * * This checks several things in order: * - recipe signature * - capabilities * - filter expression * * If the provided signature does not match the provided recipe, then * `SIGNATURE_ERROR` is returned. Recipes with this suitability should not be * trusted. These recipes are included so that temporary signature errors on * the server can be handled intelligently by actions. * * Capabilities are a simple set of strings in the recipe. If the Normandy * client has all of the capabilities listed, then execution continues. If * not, then `CAPABILITY_MISMATCH` is returned. Recipes with this suitability * should be considered incompatible and treated with caution. * * If the capabilities check passes, then the filter expression is evaluated * against the current environment. The result of the expression is cast to a * boolean. If it is true, then `FILTER_MATCH` is returned. If not, then * `FILTER_MISMATCH` is returned. * * If there is an error while evaluating the recipe's filter, `FILTER_ERROR` * is returned instead. * * @param {object} recipe * @param {object} signature * @param {string} recipe.filter_expression The expression to evaluate against the environment. * @param {Set} runnerCapabilities The capabilities provided by this runner. * @return {Promise} The recipe's suitability */ async getRecipeSuitability(recipe, signature) { let generator = this.getAllSuitabilities(recipe, signature); // For our purposes, only the first suitability matters, so pull the first // value out of the async generator. This additionally guarantees if we fail // a security or compatibility check, we won't continue to run other checks, // which is good for the general case of running recipes. let { value: suitability } = await generator.next(); switch (suitability) { case BaseAction.suitability.SIGNATURE_ERROR: { await Uptake.reportRecipe(recipe, Uptake.RECIPE_INVALID_SIGNATURE); break; } case BaseAction.suitability.CAPABILITES_MISMATCH: { await Uptake.reportRecipe( recipe, Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES ); break; } case BaseAction.suitability.FILTER_MATCH: { // No telemetry needs to be sent for this right now. break; } case BaseAction.suitability.FILTER_MISMATCH: { // This represents a terminal state for the given recipe, so // report its outcome. Others are reported when executed in // ActionsManager. await Uptake.reportRecipe(recipe, Uptake.RECIPE_DIDNT_MATCH_FILTER); break; } case BaseAction.suitability.FILTER_ERROR: { await Uptake.reportRecipe(recipe, Uptake.RECIPE_FILTER_BROKEN); break; } case BaseAction.suitability.ARGUMENTS_INVALID: { // This shouldn't ever occur, since the arguments schema is checked by // BaseAction itself. throw new Error(`Shouldn't get ${suitability} in RecipeRunner`); } default: { throw new Error(`Unexpected recipe suitability ${suitability}`); } } return suitability; }, /** * Some uses cases, such as Normandy Devtools, want the status of all * suitabilities, not only the most important one. This checks the cases of * suitabilities in order from most blocking to least blocking. The first * yielded is the "primary" suitability to pass on to actions. * * If this function yields only [FILTER_MATCH], then the recipe fully matches * and should be executed. If any other statuses are yielded, then the recipe * should not be executed as normal. * * This is a generator so that the execution can be halted as needed. For * example, after receiving a signature error, a caller can stop advancing * the iterator to avoid exposing the browser to unneeded risk. */ async *getAllSuitabilities(recipe, signature) { try { await NormandyApi.verifyObjectSignature(recipe, signature, "recipe"); } catch (e) { yield BaseAction.suitability.SIGNATURE_ERROR; } const runnerCapabilities = this.getCapabilities(); if (Array.isArray(recipe.capabilities)) { for (const recipeCapability of recipe.capabilities) { if (!runnerCapabilities.has(recipeCapability)) { log.debug( `Recipe "${recipe.name}" requires unknown capabilities. ` + `Recipe capabilities: ${JSON.stringify(recipe.capabilities)}. ` + `Local runner capabilities: ${JSON.stringify( Array.from(runnerCapabilities) )}` ); yield BaseAction.suitability.CAPABILITES_MISMATCH; } } } const context = this.getFilterContext(recipe); const targetingContext = new TargetingContext(); try { if (await targetingContext.eval(recipe.filter_expression, context)) { yield BaseAction.suitability.FILTER_MATCH; } else { yield BaseAction.suitability.FILTER_MISMATCH; } } catch (err) { log.error( `Error checking filter for "${recipe.name}". Filter: [${recipe.filter_expression}]. Error: "${err}"` ); yield BaseAction.suitability.FILTER_ERROR; } }, /** * Clear all caches of systems used by RecipeRunner, in preparation * for a clean run. */ clearCaches() { ClientEnvironment.clearClassifyCache(); NormandyApi.clearIndexCache(); }, /** * Clear out cached state and fetch/execute recipes from the given * API url. This is used mainly by the mock-recipe-server JS that is * executed in the browser console. */ async testRun(baseApiUrl) { const oldApiUrl = Services.prefs.getCharPref(API_URL_PREF); Services.prefs.setCharPref(API_URL_PREF, baseApiUrl); try { Storage.clearAllStorage(); this.clearCaches(); await this.run(); } finally { Services.prefs.setCharPref(API_URL_PREF, oldApiUrl); this.clearCaches(); } }, /** * Offer a mechanism to get access to the lazily-instantiated * gRemoteSettingsClient, because if someone instantiates it * themselves, it won't have the options we provided in this module, * and it will prevent instantiation by this module later. * * This is only meant to be used in testing, where it is a * convenient hook to store data in the underlying remote-settings * collection. */ get _remoteSettingsClientForTesting() { return gRemoteSettingsClient; }, migrations: { /** * Delete the now-unused collection of recipes, since we are using the * "normandy-recipes-capabilities" collection now. */ async migration01RemoveOldRecipesCollection() { // Don't bother to open IDB and clear on clean profiles. const lastCheckPref = "services.settings.main.normandy-recipes.last_check"; if (Services.prefs.prefHasUserValue(lastCheckPref)) { // We instantiate a client, but it won't take part of sync. const client = new RemoteSettingsClient("normandy-recipes", { bucketNamePref: "services.settings.default_bucket", }); await client.db.clear(); Services.prefs.clearUserPref(lastCheckPref); } }, }, };