Source code
Revision control
Copy as Markdown
Other Tools
/* 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
import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
import { IPPEarlyStartupFilter } from "moz-src:///toolkit/components/ipprotection/IPPEarlyStartupFilter.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
IPProtectionServerlist:
"moz-src:///toolkit/components/ipprotection/IPProtectionServerlist.sys.mjs",
IPPProxyManager:
"moz-src:///toolkit/components/ipprotection/IPPProxyManager.sys.mjs",
IPPProxyStates:
"moz-src:///toolkit/components/ipprotection/IPPProxyManager.sys.mjs",
IPProtectionService:
"moz-src:///toolkit/components/ipprotection/IPProtectionService.sys.mjs",
IPProtectionStates:
"moz-src:///toolkit/components/ipprotection/IPProtectionService.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logConsole", () =>
console.createInstance({
prefix: "IPPAlwaysOn",
maxLogLevel: Services.prefs.getBoolPref("browser.ipProtection.log", false)
? "Debug"
: "Warn",
})
);
const RESTART_BASE_DELAY_MS = 1000;
const RESTART_MAX_DELAY_MS = 30000;
/**
* Keeps the proxy connection alive on enterprise builds where the
* AccessConnector policy is active. Unlike IPPAutoStart, this class:
*
* - Recovers from ERROR states by stopping and requesting a restart.
* - Restarts when the proxy stops unexpectedly.
* - Restarts are deferred and backed off so that repeated failures don't spin the main thread.
* - Switches to a new server when the server list is updated.
*
* Because this is policy-driven there is no user-facing toggle; the proxy
* runs whenever the service is ready and the policy is set.
*/
class IPPAlwaysOnSingleton {
#initialized = false;
#shouldBeRunning = false;
#startController = null;
#restartDelayMs = 0;
constructor() {
this.handleServiceEvent = this.#handleServiceEvent.bind(this);
this.handleProxyEvent = this.#handleProxyEvent.bind(this);
this.handleServerlistEvent = this.#handleServerlistEvent.bind(this);
}
get alwaysOnEnabled() {
return !!Services.policies.getActivePolicies()?.AccessConnector;
}
init() {
if (this.#initialized || !this.alwaysOnEnabled) {
lazy.logConsole.debug(
"init() skipped — initialized:",
this.#initialized,
"alwaysOnEnabled:",
this.alwaysOnEnabled
);
return;
}
lazy.logConsole.info("Initialized");
this.#initialized = true;
lazy.IPProtectionService.addEventListener(
"IPProtectionService:StateChanged",
this.handleServiceEvent
);
lazy.IPPProxyManager.addEventListener(
"IPPProxyManager:StateChanged",
this.handleProxyEvent
);
lazy.IPProtectionServerlist.addEventListener(
"IPProtectionServerlist:ListChanged",
this.handleServerlistEvent
);
}
initOnStartupCompleted() {}
uninit() {
if (!this.#initialized) {
return;
}
this.#initialized = false;
this.#shouldBeRunning = false;
this.#cancelStartRequest();
lazy.IPProtectionService.removeEventListener(
"IPProtectionService:StateChanged",
this.handleServiceEvent
);
lazy.IPPProxyManager.removeEventListener(
"IPPProxyManager:StateChanged",
this.handleProxyEvent
);
lazy.IPProtectionServerlist.removeEventListener(
"IPProtectionServerlist:ListChanged",
this.handleServerlistEvent
);
}
/**
* Returns true if a start request would be blocked by the current state of
* the service, proxy, or server list.
*/
#isStartBlocked() {
if (
lazy.IPPProxyManager.state === lazy.IPPProxyStates.ACTIVE &&
lazy.IPPProxyManager.channelFilter()?.proxyInfo
) {
return true;
}
return !lazy.IPProtectionServerlist.hasList;
}
/**
* Requests a start of the proxy, with exponential backoff on repeated failures.
*
* @param {boolean} [resetBackoff]
* Cancel any live request and reschedule immediately with the base delay.
*/
async #requestStart(resetBackoff = false) {
if (resetBackoff) {
this.#cancelStartRequest();
}
if (this.#startController || this.#isStartBlocked()) {
return;
}
const controller = new AbortController();
this.#startController = controller;
const { signal } = controller;
const delay = this.#restartDelayMs;
this.#restartDelayMs = Math.min(
this.#restartDelayMs ? this.#restartDelayMs * 2 : RESTART_BASE_DELAY_MS,
RESTART_MAX_DELAY_MS
);
try {
await this.#wait(delay, signal);
if (signal.aborted || !this.#shouldBeRunning || !this.alwaysOnEnabled) {
return;
}
if (this.#isStartBlocked()) {
return;
}
lazy.logConsole.info("Starting proxy");
await lazy.IPPProxyManager.start(
false,
PrivateBrowsingUtils.permanentPrivateBrowsing
);
} catch (e) {
lazy.logConsole.error("Start request failed:", e);
} finally {
if (this.#startController === controller) {
this.#startController = null;
}
}
}
/**
* Releases any scheduled or in-flight start request.
*/
#abortStartRequest() {
if (this.#startController) {
this.#startController.abort();
this.#startController = null;
}
}
/**
* Cancels any scheduled or in-flight start request and resets the backoff delay.
*/
#cancelStartRequest() {
this.#abortStartRequest();
this.#restartDelayMs = 0;
}
/**
* Resolves after `ms` milliseconds, or immediately once `signal` aborts.
*
* @param {number} ms
* @param {AbortSignal} signal
* @returns {Promise<void>}
*/
#wait(ms, signal) {
const { promise, resolve } = Promise.withResolvers();
const timer = lazy.setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
lazy.clearTimeout(timer);
resolve();
},
{ once: true }
);
return promise;
}
/**
* Stops the proxy and, once it settles, requests a fresh start.
*
* @param {boolean} [resetBackoff] Reset the backoff delay to the base value.
*/
async #stopThenRestart(resetBackoff = false) {
try {
await lazy.IPPProxyManager.stop(false);
} catch (e) {
lazy.logConsole.error("Failed to stop proxy:", e);
return;
}
if (this.#shouldBeRunning && this.alwaysOnEnabled) {
this.#requestStart(resetBackoff);
}
}
#handleServiceEvent() {
const serviceState = lazy.IPProtectionService.state;
switch (serviceState) {
case lazy.IPProtectionStates.UNINITIALIZED:
case lazy.IPProtectionStates.UNAVAILABLE:
case lazy.IPProtectionStates.UNAUTHENTICATED:
this.#shouldBeRunning = false;
this.#abortStartRequest();
break;
case lazy.IPProtectionStates.READY:
this.#shouldBeRunning = true;
this.#requestStart(true);
break;
default:
break;
}
}
#handleProxyEvent() {
// alwaysOnEnabled flips to false synchronously when the policy is removed,
// before uninit() runs. Bail out so we don't restart in response to the
// ACTIVE->READY transition produced by teardown.
if (!this.#shouldBeRunning || !this.alwaysOnEnabled) {
return;
}
switch (lazy.IPPProxyManager.state) {
case lazy.IPPProxyStates.ACTIVE:
this.#cancelStartRequest();
break;
case lazy.IPPProxyStates.READY:
this.#abortStartRequest();
this.#requestStart();
break;
case lazy.IPPProxyStates.ERROR:
this.#abortStartRequest();
// Repeated failures back off; don't reset the delay.
this.#stopThenRestart();
break;
default:
break;
}
}
#handleServerlistEvent() {
if (!this.alwaysOnEnabled) {
return;
}
if (!lazy.IPProtectionServerlist.hasList) {
// Serverlist cleared (e.g. policy removed); stop any active connection.
const state = lazy.IPPProxyManager.state;
if (
state === lazy.IPPProxyStates.ACTIVE ||
state === lazy.IPPProxyStates.ERROR
) {
lazy.IPPProxyManager.stop(false);
}
return;
}
const state = lazy.IPPProxyManager.state;
switch (state) {
case lazy.IPPProxyStates.ACTIVE: {
// Hot-swap without dropping the connection.
lazy.logConsole.debug("Switching to updated server");
const { error } = lazy.IPPProxyManager.switch();
if (error) {
// Fresh server list, reset backoff so the reconnect isn't delayed.
this.#stopThenRestart(true);
}
break;
}
case lazy.IPPProxyStates.ERROR:
// A fresh server list may resolve the error; reset backoff to retry promptly.
if (this.#shouldBeRunning) {
this.#stopThenRestart(true);
}
break;
case lazy.IPPProxyStates.READY:
if (this.#shouldBeRunning) {
this.#requestStart(true);
}
break;
default:
break;
}
}
}
const IPPAlwaysOn = new IPPAlwaysOnSingleton();
const IPPAlwaysOnHelpers = [
IPPAlwaysOn,
new IPPEarlyStartupFilter(() => IPPAlwaysOn.alwaysOnEnabled),
];
export { IPPAlwaysOnHelpers, IPPAlwaysOnSingleton };