commit ecab32c779fc4877e1563ac06af5eb901d2b18a1
parent 32a0f944dc91a5f86e87540515f05559162100eb
Author: Andrea Marchesini <amarchesini@mozilla.com>
Date: Fri, 24 Oct 2025 15:57:04 +0000
Bug 1993191 - IPProtection: Fetch the server list as soon as possible before creating the channel filter, r=ip-protection-reviewers,fchasen
Differential Revision: https://phabricator.services.mozilla.com/D269946
Diffstat:
9 files changed, 215 insertions(+), 64 deletions(-)
diff --git a/browser/components/ipprotection/IPPAutoStart.sys.mjs b/browser/components/ipprotection/IPPAutoStart.sys.mjs
@@ -7,6 +7,8 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
+ IPProtectionServerlist:
+ "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs",
IPProtectionService:
"resource:///modules/ipprotection/IPProtectionService.sys.mjs",
IPProtectionStates:
@@ -20,13 +22,13 @@ const AUTOSTART_PREF = "browser.ipProtection.autoStartEnabled";
* calls `start()`. This is done only if the previous state was not a ACTIVE
* because, in that case, more likely the VPN on/off state is an user decision.
*/
-class IPPAutoStart {
+class IPPAutoStartSingleton {
#shouldStartWhenReady = false;
constructor() {
XPCOMUtils.defineLazyPreferenceGetter(
this,
- "autoStart",
+ "autoStartPref",
AUTOSTART_PREF,
false,
(_pref, _oldVal, featureEnabled) => {
@@ -65,6 +67,12 @@ class IPPAutoStart {
}
}
+ get autoStart() {
+ // We activate the auto-start feature only if the pref is true and we have
+ // the serverlist already.
+ return this.autoStartPref && lazy.IPProtectionServerlist.hasList;
+ }
+
#handleEvent(_event) {
switch (lazy.IPProtectionService.state) {
case lazy.IPProtectionStates.UNINITIALIZED:
@@ -87,6 +95,8 @@ class IPPAutoStart {
}
}
+const IPPAutoStart = new IPPAutoStartSingleton();
+
/**
* This class monitors the startup phases and registers/unregisters the channel
* filter to avoid data leak. The activation of the VPN is done by the
@@ -97,11 +107,7 @@ class IPPEarlyStartupFilter {
constructor() {
this.handleEvent = this.#handleEvent.bind(this);
-
- this.#autoStartAndAtStartup = Services.prefs.getBoolPref(
- AUTOSTART_PREF,
- false
- );
+ this.#autoStartAndAtStartup = IPPAutoStart.autoStart;
}
init() {
@@ -154,6 +160,6 @@ class IPPEarlyStartupFilter {
}
}
-const IPPAutoStartHelpers = [new IPPAutoStart(), new IPPEarlyStartupFilter()];
+const IPPAutoStartHelpers = [IPPAutoStart, new IPPEarlyStartupFilter()];
export { IPPAutoStartHelpers };
diff --git a/browser/components/ipprotection/IPPProxyManager.sys.mjs b/browser/components/ipprotection/IPPProxyManager.sys.mjs
@@ -11,9 +11,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
"resource:///modules/ipprotection/IPProtectionUsage.sys.mjs",
IPPNetworkErrorObserver:
"resource:///modules/ipprotection/IPPNetworkErrorObserver.sys.mjs",
- getDefaultLocation:
- "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs",
- selectServer:
+ IPProtectionServerlist:
"resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs",
});
@@ -117,8 +115,13 @@ class IPPProxyManager {
this.#pass = await this.#getProxyPass();
}
- const location = await lazy.getDefaultLocation();
- const server = await lazy.selectServer(location?.city);
+ const location = lazy.IPProtectionServerlist.getDefaultLocation();
+ const server = lazy.IPProtectionServerlist.selectServer(location?.city);
+ if (!server) {
+ lazy.logConsole.error("No server found");
+ throw new Error("No server found");
+ }
+
lazy.logConsole.debug("Server:", server?.hostname);
this.#connection.initialize(
diff --git a/browser/components/ipprotection/IPPStartupCache.sys.mjs b/browser/components/ipprotection/IPPStartupCache.sys.mjs
@@ -13,6 +13,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
const STATE_CACHE_PREF = "browser.ipProtection.stateCache";
const ENTITLEMENT_CACHE_PREF = "browser.ipProtection.entitlementCache";
+const LOCATIONLIST_CACHE_PREF = "browser.ipProtection.locationListCache";
/**
* This class implements a cache for the IPP state machine. The cache is used
@@ -118,6 +119,25 @@ class IPPStartupCacheSingleton {
}
}
+ storeLocationList(locationList) {
+ Services.prefs.setCharPref(
+ LOCATIONLIST_CACHE_PREF,
+ JSON.stringify(locationList)
+ );
+ }
+
+ get locationList() {
+ try {
+ const locationList = Services.prefs.getCharPref(
+ LOCATIONLIST_CACHE_PREF,
+ ""
+ );
+ return JSON.parse(locationList);
+ } catch (e) {
+ return null;
+ }
+ }
+
#handleEvent(_event) {
const state = lazy.IPProtectionService.state;
if (this.#startupCompleted) {
diff --git a/browser/components/ipprotection/IPProtectionHelpers.sys.mjs b/browser/components/ipprotection/IPProtectionHelpers.sys.mjs
@@ -26,6 +26,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
import { IPPAutoStartHelpers } from "resource:///modules/ipprotection/IPPAutoStart.sys.mjs";
import { IPPEnrollHelper } from "resource:///modules/ipprotection/IPPEnrollHelper.sys.mjs";
import { IPPNimbusHelper } from "resource:///modules/ipprotection/IPPNimbusHelper.sys.mjs";
+import { IPProtectionServerlist } from "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs";
import { IPPSignInWatcher } from "resource:///modules/ipprotection/IPPSignInWatcher.sys.mjs";
import { IPPStartupCache } from "resource:///modules/ipprotection/IPPStartupCache.sys.mjs";
@@ -150,6 +151,7 @@ const IPPHelpers = [
IPPStartupCache,
IPPSignInWatcher,
IPPEnrollHelper,
+ IPProtectionServerlist,
new UIHelper(),
new AccountResetHelper(),
new VPNAddonHelper(),
diff --git a/browser/components/ipprotection/IPProtectionServerlist.sys.mjs b/browser/components/ipprotection/IPProtectionServerlist.sys.mjs
@@ -2,58 +2,26 @@
* 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/. */
-import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs";
-
/**
* This file contains functions that work on top of the RemoteSettings
* Bucket for the IP Protection server list.
*/
-/**
- * Selects a default location - for alpha this is only the US.
- *
- * @param {RemoteSettingsClient} bucket - The RemoteSettings bucket to use
- * @returns {{Country, City}} - The best country/city to use.
- */
-export async function getDefaultLocation(
- bucket = RemoteSettings("vpn-serverlist")
-) {
- const list = await bucket.get();
- /** @type {Country} */
- const usa = list.find(country => country.code === "US");
- if (!usa) {
- return null;
- }
- const city = usa.cities.find(c => c.servers.length);
- return {
- city,
- country: usa,
- };
-}
+const lazy = {};
-/**
- * Given a city, it selects an available server.
- *
- * @param {City?} city
- * @returns {Server|null}
- */
-export async function selectServer(city) {
- if (!city) {
- return null;
- }
- const servers = city.servers.filter(server => !server.quarantined);
- if (servers.length === 1) {
- return servers[0];
- } else if (servers.length > 1) {
- return servers[Math.floor(Math.random() * servers.length)];
- }
- return null;
-}
+ChromeUtils.defineESModuleGetters(lazy, {
+ IPPStartupCache: "resource:///modules/ipprotection/IPPStartupCache.sys.mjs",
+ IPProtectionService:
+ "resource:///modules/ipprotection/IPProtectionService.sys.mjs",
+ IPProtectionStates:
+ "resource:///modules/ipprotection/IPProtectionService.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+});
/**
* Class representing a server.
*/
-export class Server {
+class Server {
/**
* Port of the server
*
@@ -73,12 +41,18 @@ export class Server {
* @type {boolean}
*/
quarantined = false;
+
+ constructor(data) {
+ this.port = data.port || 443;
+ this.hostname = data.hostname || "";
+ this.quarantined = !!data.quarantined;
+ }
}
/**
* Class representing a city.
*/
-export class City {
+class City {
/**
* Fallback name for the city if not available
*
@@ -98,12 +72,18 @@ export class City {
* @type {Server[]}
*/
servers = [];
+
+ constructor(data) {
+ this.name = data.name || "";
+ this.code = data.code || "";
+ this.servers = (data.servers || []).map(s => new Server(s));
+ }
}
/**
* Class representing a country.
*/
-export class Country {
+class Country {
/**
* Fallback name for the country if not available
*
@@ -124,4 +104,128 @@ export class Country {
* @type {City[]}
*/
cities;
+
+ constructor(data) {
+ this.name = data.name || "";
+ this.code = data.code || "";
+ this.cities = (data.cities || []).map(c => new City(c));
+ }
+}
+
+/**
+ *
+ */
+class IPProtectionServerlistSingleton {
+ #list = null;
+ #runningPromise = null;
+
+ constructor() {
+ this.handleEvent = this.#handleEvent.bind(this);
+ this.#list = IPProtectionServerlistSingleton.#dataToList(
+ lazy.IPPStartupCache.locationList
+ );
+ }
+
+ init() {
+ lazy.IPProtectionService.addEventListener(
+ "IPProtectionService:StateChanged",
+ this.handleEvent
+ );
+ }
+
+ async initOnStartupCompleted() {}
+
+ uninit() {
+ lazy.IPProtectionService.removeEventListener(
+ "IPProtectionService:StateChanged",
+ this.handleEvent
+ );
+ }
+
+ #handleEvent(_event) {
+ if (lazy.IPProtectionService.state === lazy.IPProtectionStates.READY) {
+ this.maybeFetchList();
+ }
+ }
+
+ maybeFetchList() {
+ if (this.#list.length !== 0) {
+ return Promise.resolve();
+ }
+
+ if (this.#runningPromise) {
+ return this.#runningPromise;
+ }
+
+ const fetchList = async () => {
+ const bucket = lazy.RemoteSettings("vpn-serverlist");
+ this.#list = IPProtectionServerlistSingleton.#dataToList(
+ await bucket.get()
+ );
+ };
+
+ this.#runningPromise = fetchList().finally(
+ () => (this.#runningPromise = null)
+ );
+
+ return this.#runningPromise;
+ }
+
+ /**
+ * Selects a default location - for alpha this is only the US.
+ *
+ * @returns {{Country, City}} - The best country/city to use.
+ */
+ getDefaultLocation() {
+ /** @type {Country} */
+ const usa = this.#list.find(country => country.code === "US");
+ if (!usa) {
+ return null;
+ }
+
+ const city = usa.cities.find(c => c.servers.length);
+ return {
+ city,
+ country: usa,
+ };
+ }
+
+ /**
+ * Given a city, it selects an available server.
+ *
+ * @param {City?} city
+ * @returns {Server|null}
+ */
+ selectServer(city) {
+ if (!city) {
+ return null;
+ }
+
+ const servers = city.servers.filter(server => !server.quarantined);
+ if (servers.length === 1) {
+ return servers[0];
+ }
+
+ if (servers.length > 1) {
+ return servers[Math.floor(Math.random() * servers.length)];
+ }
+
+ return null;
+ }
+
+ get hasList() {
+ return this.#list.length !== 0;
+ }
+
+ static #dataToList(list) {
+ if (!Array.isArray(list)) {
+ return [];
+ }
+
+ return list.map(c => new Country(c));
+ }
}
+
+const IPProtectionServerlist = new IPProtectionServerlistSingleton();
+
+export { IPProtectionServerlist };
diff --git a/browser/components/ipprotection/IPProtectionService.sys.mjs b/browser/components/ipprotection/IPProtectionService.sys.mjs
@@ -12,6 +12,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
IPPHelpers: "resource:///modules/ipprotection/IPProtectionHelpers.sys.mjs",
IPPNimbusHelper: "resource:///modules/ipprotection/IPPNimbusHelper.sys.mjs",
IPPProxyManager: "resource:///modules/ipprotection/IPPProxyManager.sys.mjs",
+ IPProtectionServerlist:
+ "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs",
IPPSignInWatcher: "resource:///modules/ipprotection/IPPSignInWatcher.sys.mjs",
IPPStartupCache: "resource:///modules/ipprotection/IPPStartupCache.sys.mjs",
SpecialMessageActions:
@@ -203,6 +205,8 @@ class IPProtectionServiceSingleton extends EventTarget {
* True if started by user action, false if system action
*/
async start(userAction = true) {
+ await lazy.IPProtectionServerlist.maybeFetchList();
+
// Wait for enrollment to finish.
const enrollData = await lazy.IPPEnrollHelper.maybeEnroll();
if (!enrollData || !enrollData.isEnrolled) {
diff --git a/browser/components/ipprotection/tests/browser/browser_IPPProxyManager.js b/browser/components/ipprotection/tests/browser/browser_IPPProxyManager.js
@@ -7,6 +7,9 @@
const { IPPProxyManager } = ChromeUtils.importESModule(
"resource:///modules/ipprotection/IPPProxyManager.sys.mjs"
);
+const { IPProtectionServerlist } = ChromeUtils.importESModule(
+ "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs"
+);
// Don't add an experiment so we can test adding and removing it.
DEFAULT_EXPERIMENT = null;
@@ -20,6 +23,8 @@ add_task(async function test_IPPProxyManager_handleProxyErrorEvent() {
let proxyManager = new IPPProxyManager(IPProtectionService.guardian);
+ await IPProtectionServerlist.maybeFetchList();
+
await proxyManager.start();
const cases = [
diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProtectionServerlist.js b/browser/components/ipprotection/tests/xpcshell/test_IPProtectionServerlist.js
@@ -3,7 +3,7 @@ https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
-const { getDefaultLocation, selectServer } = ChromeUtils.importESModule(
+const { IPProtectionServerlist } = ChromeUtils.importESModule(
"resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs"
);
@@ -59,17 +59,19 @@ add_setup(async function () {
await client.db.create(country);
}
await client.db.importChanges({}, Date.now());
+
+ await IPProtectionServerlist.maybeFetchList();
});
add_task(async function test_getDefaultLocation() {
- const { country, city } = await getDefaultLocation();
+ const { country, city } = IPProtectionServerlist.getDefaultLocation();
Assert.equal(country.code, "US", "The default country should be US");
Assert.deepEqual(city, TEST_US_CITY, "The correct city should be returned");
});
add_task(async function test_selectServer() {
// Test with a city with multiple non-quarantined servers
- let selected = await selectServer(TEST_US_CITY);
+ let selected = IPProtectionServerlist.selectServer(TEST_US_CITY);
Assert.ok(
[TEST_SERVER_1, TEST_SERVER_2].some(s => s.hostname === selected.hostname),
"A valid server should be selected"
@@ -81,7 +83,7 @@ add_task(async function test_selectServer() {
code: "OSC",
servers: [TEST_SERVER_1],
};
- selected = await selectServer(cityWithOneServer);
+ selected = IPProtectionServerlist.selectServer(cityWithOneServer);
Assert.deepEqual(
selected,
TEST_SERVER_1,
@@ -94,7 +96,7 @@ add_task(async function test_selectServer() {
code: "MSC",
servers: [TEST_SERVER_1, TEST_SERVER_QUARANTINED],
};
- selected = await selectServer(cityWithMixedServers);
+ selected = IPProtectionServerlist.selectServer(cityWithMixedServers);
Assert.deepEqual(
selected,
TEST_SERVER_1,
@@ -107,7 +109,7 @@ add_task(async function test_selectServer() {
code: "QC",
servers: [TEST_SERVER_QUARANTINED],
};
- selected = await selectServer(cityWithQuarantinedServers);
+ selected = IPProtectionServerlist.selectServer(cityWithQuarantinedServers);
Assert.equal(selected, null, "No server should be selected");
// Test with a city with no servers
@@ -116,6 +118,6 @@ add_task(async function test_selectServer() {
code: "NSC",
servers: [],
};
- selected = await selectServer(cityWithNoServers);
+ selected = IPProtectionServerlist.selectServer(cityWithNoServers);
Assert.equal(selected, null, "No server should be selected");
});
diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js b/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js
@@ -7,6 +7,9 @@
const { IPPProxyManager } = ChromeUtils.importESModule(
"resource:///modules/ipprotection/IPPProxyManager.sys.mjs"
);
+const { IPProtectionServerlist } = ChromeUtils.importESModule(
+ "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs"
+);
const { GuardianClient } = ChromeUtils.importESModule(
"resource:///modules/ipprotection/GuardianClient.sys.mjs"
);
@@ -31,6 +34,8 @@ add_task(async function test_IPPProxyManager_start_stop_reset() {
},
});
+ await IPProtectionServerlist.maybeFetchList();
+
let proxyManager = new IPPProxyManager(guardian);
await proxyManager.start();