commit 525c427e1739162bb06852467d01731555a9ed1e
parent 6da49d548d3373b95591b968e764f405a1535e0d
Author: Sebastian Streich <sstreich@mozilla.com>
Date: Mon, 15 Dec 2025 16:36:46 +0000
Bug 1996529 - Add Optional Config option to force a specific server r=ip-protection-reviewers,baku
Differential Revision: https://phabricator.services.mozilla.com/D273986
Diffstat:
3 files changed, 203 insertions(+), 59 deletions(-)
diff --git a/browser/components/ipprotection/IPProtectionServerlist.sys.mjs b/browser/components/ipprotection/IPProtectionServerlist.sys.mjs
@@ -181,20 +181,95 @@ class Country {
}
/**
- *
+ * Base Class for the Serverlist
*/
-class IPProtectionServerlistSingleton {
- #list = null;
- #runningPromise = null;
+export class IPProtectionServerlistBase {
+ __list = null;
+
+ init() {}
+
+ async initOnStartupCompleted() {}
+
+ uninit() {}
+
+ /**
+ * Tries to refresh the list from the underlining source.
+ *
+ * @param {*} _forceUpdate - if true, forces a refresh even if the list is already populated.
+ */
+ maybeFetchList(_forceUpdate = false) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * 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));
+ }
+}
+
+/**
+ * Class representing the IP Protection Serverlist
+ * fetched from Remote Settings.
+ */
+export class RemoteSettingsServerlist extends IPProtectionServerlistBase {
#bucket = null;
+ #runningPromise = null;
constructor() {
+ super();
this.handleEvent = this.#handleEvent.bind(this);
- this.#list = IPProtectionServerlistSingleton.#dataToList(
+ this.__list = IPProtectionServerlistBase.dataToList(
lazy.IPPStartupCache.locationList
);
}
-
init() {
lazy.IPProtectionService.addEventListener(
"IPProtectionService:StateChanged",
@@ -222,7 +297,7 @@ class IPProtectionServerlistSingleton {
}
maybeFetchList(forceUpdate = false) {
- if (this.#list.length !== 0 && !forceUpdate) {
+ if (this.__list.length !== 0 && !forceUpdate) {
return Promise.resolve();
}
@@ -231,11 +306,11 @@ class IPProtectionServerlistSingleton {
}
const fetchList = async () => {
- this.#list = IPProtectionServerlistSingleton.#dataToList(
+ this.__list = IPProtectionServerlistBase.dataToList(
await this.bucket.get()
);
- lazy.IPPStartupCache.storeLocationList(this.#list);
+ lazy.IPPStartupCache.storeLocationList(this.__list);
};
this.#runningPromise = fetchList().finally(
@@ -245,68 +320,84 @@ class IPProtectionServerlistSingleton {
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;
+ get bucket() {
+ if (!this.#bucket) {
+ this.#bucket = lazy.RemoteSettings("vpn-serverlist");
}
-
- const city = usa.cities.find(c => c.servers.length);
- return {
- city,
- country: usa,
- };
+ return this.#bucket;
}
+}
+/**
+ * Class representing the IP Protection Serverlist
+ * from about:config preferences.
+ */
+export class PrefServerList extends IPProtectionServerlistBase {
+ #observer = null;
- /**
- * 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)];
- }
+ constructor() {
+ super();
+ this.#observer = this.onPrefChange.bind(this);
+ this.maybeFetchList();
+ }
- return null;
+ onPrefChange() {
+ this.maybeFetchList();
}
- get hasList() {
- return this.#list.length !== 0;
+ async initOnStartupCompleted() {
+ Services.prefs.addObserver(
+ IPProtectionServerlist.PREF_NAME,
+ this.#observer
+ );
}
- get bucket() {
- if (!this.#bucket) {
- this.#bucket = lazy.RemoteSettings("vpn-serverlist");
- }
- return this.#bucket;
+ uninit() {
+ Services.prefs.removeObserver(
+ IPProtectionServerlist.PREF_NAME,
+ this.#observer
+ );
+ }
+ maybeFetchList(_forceUpdate = false) {
+ this.__list = IPProtectionServerlistBase.dataToList(
+ PrefServerList.prefValue
+ );
+ return Promise.resolve();
}
- static #dataToList(list) {
- if (!Array.isArray(list)) {
- return [];
+ static get PREF_NAME() {
+ return "browser.ipProtection.override.serverlist";
+ }
+ /**
+ * Returns true if the preference has a valid value.
+ */
+ static get hasPrefValue() {
+ return (
+ Services.prefs.getPrefType(this.PREF_NAME) ===
+ Services.prefs.PREF_STRING &&
+ !!Services.prefs.getStringPref(this.PREF_NAME).length
+ );
+ }
+ static get prefValue() {
+ try {
+ const value = Services.prefs.getStringPref(this.PREF_NAME);
+ return JSON.parse(value);
+ } catch (e) {
+ console.error(`IPProtection: Error parsing serverlist pref value: ${e}`);
+ return null;
}
-
- return list.map(c => new Country(c));
}
}
+/**
+ *
+ * @returns {IPProtectionServerlistBase} - The appropriate serverlist implementation.
+ */
+export function IPProtectionServerlistFactory() {
+ return PrefServerList.hasPrefValue
+ ? new PrefServerList()
+ : new RemoteSettingsServerlist();
+}
-const IPProtectionServerlist = new IPProtectionServerlistSingleton();
+// Only check once which implementation to use.
+const IPProtectionServerlist = IPProtectionServerlistFactory();
export { IPProtectionServerlist };
diff --git a/browser/components/ipprotection/docs/Preferences.rst b/browser/components/ipprotection/docs/Preferences.rst
@@ -61,6 +61,9 @@ Networking and routing
``browser.ipProtection.domainExclusions`` (string)
Comma‑separated list of domains to exclude from the proxy.
+``browser.ipProtection.override.serverlist`` (string)
+ A JSON Payload that overrides the server list. Follows the Remote-Settings Schema.
+
Diagnostics
~~~~~~~~~~~
diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProtectionServerlist.js b/browser/components/ipprotection/tests/xpcshell/test_IPProtectionServerlist.js
@@ -3,7 +3,12 @@ https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
-const { IPProtectionServerlist } = ChromeUtils.importESModule(
+const {
+ IPProtectionServerlist,
+ PrefServerList,
+ RemoteSettingsServerlist,
+ IPProtectionServerlistFactory,
+} = ChromeUtils.importESModule(
"resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs"
);
@@ -86,6 +91,7 @@ add_setup(async function () {
await IPProtectionServerlist.maybeFetchList();
await IPProtectionServerlist.initOnStartupCompleted();
+ Assert.ok(IPProtectionServerlist instanceof RemoteSettingsServerlist);
});
add_task(async function test_getDefaultLocation() {
@@ -177,3 +183,47 @@ add_task(async function test_syncRespected() {
Assert.equal(country.code, "US", "The default country should be US");
Assert.deepEqual(city, updated_city, "The updated city should be returned");
});
+
+add_task(async function test_PrefServerList() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PrefServerList.PREF_NAME);
+ });
+ Services.prefs.setCharPref(
+ PrefServerList.PREF_NAME,
+ JSON.stringify(TEST_COUNTRIES)
+ );
+
+ Assert.equal(
+ PrefServerList.hasPrefValue,
+ true,
+ "PrefServerList should have a pref value set."
+ );
+ Assert.deepEqual(
+ PrefServerList.prefValue,
+ TEST_COUNTRIES,
+ "PrefServerList's pref value should match the set value."
+ );
+
+ const serverList = new PrefServerList();
+ await serverList.maybeFetchList();
+
+ const { country, city } = serverList.getDefaultLocation();
+ Assert.equal(country.code, "US", "The default country should be US");
+ Assert.deepEqual(city, TEST_US_CITY, "The default city should be returned.");
+});
+
+add_task(async function test_IPProtectionServerlistFactory() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PrefServerList.PREF_NAME);
+ });
+ // Without the pref set, it should return RemoteSettingsServerlist
+ Services.prefs.clearUserPref(PrefServerList.PREF_NAME);
+ let instance = IPProtectionServerlistFactory();
+ Assert.ok(instance instanceof RemoteSettingsServerlist);
+ Services.prefs.setCharPref(
+ PrefServerList.PREF_NAME,
+ JSON.stringify(TEST_COUNTRIES)
+ );
+ // With the pref set, it should return PrefServerList
+ Assert.ok(IPProtectionServerlistFactory() instanceof PrefServerList);
+});