/* 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/. */ /** * This file contains functions that work on top of the RemoteSettings * Bucket for the IP Protection server list. */ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const lazy = {}; ChromeUtils.defineLazyGetter(lazy, "logConsole", () => console.createInstance({ prefix: "IPProtectionServerlist", maxLogLevel: Services.prefs.getBoolPref("browser.ipProtection.log", false) ? "Debug" : "Warn", }) ); ChromeUtils.defineESModuleGetters(lazy, { IPPStartupCache: "moz-src:///toolkit/components/ipprotection/IPPStartupCache.sys.mjs", IPProtectionService: "moz-src:///toolkit/components/ipprotection/IPProtectionService.sys.mjs", IPProtectionStates: "moz-src:///toolkit/components/ipprotection/IPProtectionService.sys.mjs", RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", }); /** * Reserved country code used in the Remote Settings `vpn-serverlist` * collection to mark the anycast / recommended entry. Excluded from the * user-facing `countries` enumeration. */ export const RECOMMENDED_COUNTRY_CODE = "REC"; /** * Event dispatched by `IPProtectionServerlistBase` instances whenever the * underlying list has been replaced (RS sync, pref change, initial fetch). */ const LIST_CHANGED_EVENT = "IPProtectionServerlist:ListChanged"; /** * */ export class IProtocol { name = ""; static construct(data) { switch (data.name) { case "masque": return new MasqueProtocol(data); case "connect": return new ConnectProtocol(data); default: throw new Error("Unknown protocol: " + data.name); } } } /** * */ export class MasqueProtocol extends IProtocol { name = "masque"; host = ""; port = 0; templateString = ""; constructor(data) { super(); this.host = data.host || ""; this.port = data.port || 0; this.templateString = data.templateString || ""; } } /** * */ export class ConnectProtocol extends IProtocol { name = "connect"; host = ""; port = 0; scheme = "https"; constructor(data) { super(); this.host = data.host || ""; this.port = data.port || 0; this.scheme = data.scheme || "https"; } } /** * Class representing a server. */ export class Server { /** * Port of the server * * @type {number} */ port = 443; /** * Hostname of the server * * @type {string} */ hostname = ""; /** * If true the server is quarantined * and should not be used * * @type {boolean} */ quarantined = false; /** * List of supported protocols * * @type {Array} */ protocols = []; constructor(data) { this.port = data.port || 443; this.hostname = data.hostname || ""; this.quarantined = !!data.quarantined; this.protocols = (data.protocols || []).map(p => IProtocol.construct(p)); // Default to connect if no protocols are specified if (this.protocols.length === 0) { this.protocols = [ new ConnectProtocol({ name: "connect", host: this.hostname, port: this.port, }), ]; } } } /** * Class representing a city. */ class City { /** * Fallback name for the city if not available * * @type {string} */ name = ""; /** * A stable identifier for the city * (Usually a Wikidata ID) * * @type {string} */ code = ""; /** * List of servers in this 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. */ class Country { /** * Fallback name for the country if not available * * @type {string} */ name; /** * A stable identifier for the country * Usually a ISO 3166-1 alpha-2 code * * @type {string} */ code; /** * List of cities in this country * * @type {City[]} */ cities; constructor(data) { this.name = data.name || ""; this.code = data.code || ""; this.cities = (data.cities || []).map(c => new City(c)); } } /** * Base Class for the Serverlist */ export class IPProtectionServerlistBase extends EventTarget { __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"); } /** * Enumerates countries known to the serverlist, excluding the reserved * recommended (anycast) entry. * * @returns {Array<{code: string, available: boolean}>} - One entry per * country. `code` is an ISO 3166-1 alpha-2 code. `available` is true iff * the country has at least one city containing a non-quarantined server. */ get countries() { return this.__list .filter(country => country.code !== RECOMMENDED_COUNTRY_CODE) .map(country => ({ code: country.code, available: country.cities.some(city => city.servers.some(server => !server.quarantined) ), })); } /** * Resolves a country code to a usable {country, city} pair. * * @param {string} [countryCode=RECOMMENDED_COUNTRY_CODE] * ISO 3166-1 alpha-2 country code, or the reserved recommended code. * Defaults to the recommended entry. * @returns {{country: Country, city: City}|null} - The first city with * servers in the requested country, or null if the country is absent or * has no usable city. */ getLocation(countryCode = RECOMMENDED_COUNTRY_CODE) { const country = this.__list.find(c => c.code === countryCode); if (!country) { return null; } const city = country.cities.find(c => c.servers.length); if (!city) { return null; } return { country, city }; } /** * Returns the recommended (anycast) location, falling back to the US entry * when the Remote Settings collection has not shipped a `REC` entry yet. * * @returns {{country: Country, city: City}|null} */ getRecommendedLocation() { return this.getLocation(RECOMMENDED_COUNTRY_CODE) ?? this.getLocation("US"); } /** * 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 = IPProtectionServerlistBase.dataToList( lazy.IPPStartupCache.locationList ); } init() { lazy.IPProtectionService.addEventListener( "IPProtectionService:StateChanged", this.handleEvent ); } async initOnStartupCompleted() { this.bucket.on("sync", async () => { await this.maybeFetchList(true); }); } uninit() { lazy.IPProtectionService.removeEventListener( "IPProtectionService:StateChanged", this.handleEvent ); } #handleEvent(_event) { if (lazy.IPProtectionService.state === lazy.IPProtectionStates.READY) { this.maybeFetchList(); } } maybeFetchList(forceUpdate = false) { if (this.__list.length !== 0 && !forceUpdate) { return Promise.resolve(); } if (this.#runningPromise) { return this.#runningPromise; } const fetchList = async () => { this.__list = IPProtectionServerlistBase.dataToList( await this.bucket.get() ); lazy.IPPStartupCache.storeLocationList(this.__list); this.dispatchEvent(new Event(LIST_CHANGED_EVENT)); }; this.#runningPromise = fetchList().finally( () => (this.#runningPromise = null) ); return this.#runningPromise; } get bucket() { if (!this.#bucket) { this.#bucket = lazy.RemoteSettings("vpn-serverlist"); } return this.#bucket; } } /** * Class representing the IP Protection Serverlist * from about:config preferences. */ export class PrefServerList extends IPProtectionServerlistBase { #observer = null; constructor() { super(); this.#observer = this.onPrefChange.bind(this); this.maybeFetchList(); } onPrefChange() { this.maybeFetchList(); } async initOnStartupCompleted() { Services.prefs.addObserver( IPProtectionServerlist.PREF_NAME, this.#observer ); } uninit() { Services.prefs.removeObserver( IPProtectionServerlist.PREF_NAME, this.#observer ); } maybeFetchList(_forceUpdate = false) { this.__list = IPProtectionServerlistBase.dataToList( PrefServerList.prefValue ); this.dispatchEvent(new Event(LIST_CHANGED_EVENT)); return Promise.resolve(); } 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) { lazy.logConsole.error( `IPProtection: Error parsing serverlist pref value: ${e}` ); return null; } } } /** * * @returns {IPProtectionServerlistBase} - The appropriate serverlist implementation. */ export function IPProtectionServerlistFactory() { if (AppConstants.MOZ_ENTERPRISE) { return new PrefServerList(); } return PrefServerList.hasPrefValue ? new PrefServerList() : new RemoteSettingsServerlist(); } // Only check once which implementation to use. const IPProtectionServerlist = IPProtectionServerlistFactory(); export { IPProtectionServerlist };