IPPProxyManager.sys.mjs (14005B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 IPPEnrollAndEntitleManager: 9 "moz-src:///browser/components/ipprotection/IPPEnrollAndEntitleManager.sys.mjs", 10 IPPChannelFilter: 11 "moz-src:///browser/components/ipprotection/IPPChannelFilter.sys.mjs", 12 IPProtectionUsage: 13 "moz-src:///browser/components/ipprotection/IPProtectionUsage.sys.mjs", 14 IPPNetworkErrorObserver: 15 "moz-src:///browser/components/ipprotection/IPPNetworkErrorObserver.sys.mjs", 16 IPProtectionServerlist: 17 "moz-src:///browser/components/ipprotection/IPProtectionServerlist.sys.mjs", 18 IPProtectionService: 19 "moz-src:///browser/components/ipprotection/IPProtectionService.sys.mjs", 20 IPProtectionStates: 21 "moz-src:///browser/components/ipprotection/IPProtectionService.sys.mjs", 22 }); 23 24 ChromeUtils.defineLazyGetter( 25 lazy, 26 "setTimeout", 27 () => 28 ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") 29 .setTimeout 30 ); 31 ChromeUtils.defineLazyGetter( 32 lazy, 33 "clearTimeout", 34 () => 35 ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") 36 .clearTimeout 37 ); 38 39 import { ERRORS } from "chrome://browser/content/ipprotection/ipprotection-constants.mjs"; 40 41 const LOG_PREF = "browser.ipProtection.log"; 42 const MAX_ERROR_HISTORY = 50; 43 44 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { 45 return console.createInstance({ 46 prefix: "IPPProxyManager", 47 maxLogLevel: Services.prefs.getBoolPref(LOG_PREF, false) ? "Debug" : "Warn", 48 }); 49 }); 50 51 /** 52 * @typedef {object} IPPProxyStates 53 * List of the possible states of the IPPProxyManager. 54 * @property {string} NOT_READY 55 * The proxy is not ready because the main state machine is not in the READY state. 56 * @property {string} READY 57 * The proxy is ready to be activated. 58 * @property {string} ACTIVE 59 * The proxy is active. 60 * @property {string} ERROR 61 * Error 62 * 63 * Note: If you update this list of states, make sure to update the 64 * corresponding documentation in the `docs` folder as well. 65 */ 66 export const IPPProxyStates = Object.freeze({ 67 NOT_READY: "not-ready", 68 READY: "ready", 69 ACTIVATING: "activating", 70 ACTIVE: "active", 71 ERROR: "error", 72 }); 73 74 /** 75 * Manages the proxy connection for the IPProtectionService. 76 */ 77 class IPPProxyManagerSingleton extends EventTarget { 78 #state = IPPProxyStates.NOT_READY; 79 80 #activatingPromise = null; 81 82 #pass = null; 83 /**@type {import("./IPPChannelFilter.sys.mjs").IPPChannelFilter | null} */ 84 #connection = null; 85 #usageObserver = null; 86 #networkErrorObserver = null; 87 // If this is set, we're awaiting a proxy pass rotation 88 #rotateProxyPassPromise = null; 89 #activatedAt = false; 90 91 #rotationTimer = 0; 92 93 errors = []; 94 95 constructor() { 96 super(); 97 98 this.setErrorState = this.#setErrorState.bind(this); 99 this.handleProxyErrorEvent = this.#handleProxyErrorEvent.bind(this); 100 this.handleEvent = this.#handleEvent.bind(this); 101 } 102 103 init() { 104 lazy.IPProtectionService.addEventListener( 105 "IPProtectionService:StateChanged", 106 this.handleEvent 107 ); 108 } 109 110 initOnStartupCompleted() {} 111 112 uninit() { 113 lazy.IPProtectionService.removeEventListener( 114 "IPProtectionService:StateChanged", 115 this.handleEvent 116 ); 117 118 this.errors = []; 119 120 if ( 121 this.#state === IPPProxyStates.ACTIVE || 122 this.#state === IPPProxyStates.ACTIVATING 123 ) { 124 this.stop(false); 125 } 126 127 this.reset(); 128 this.#connection = null; 129 this.usageObserver.stop(); 130 } 131 132 /** 133 * Checks if the proxy is active and was activated. 134 * 135 * @returns {Date} 136 */ 137 get activatedAt() { 138 return this.#state === IPPProxyStates.ACTIVE && this.#activatedAt; 139 } 140 141 get usageObserver() { 142 if (!this.#usageObserver) { 143 this.#usageObserver = new lazy.IPProtectionUsage(); 144 } 145 return this.#usageObserver; 146 } 147 148 get networkErrorObserver() { 149 if (!this.#networkErrorObserver) { 150 this.#networkErrorObserver = new lazy.IPPNetworkErrorObserver(); 151 this.#networkErrorObserver.addEventListener( 152 "proxy-http-error", 153 this.handleProxyErrorEvent 154 ); 155 } 156 return this.#networkErrorObserver; 157 } 158 159 get active() { 160 return this.#state === IPPProxyStates.ACTIVE; 161 } 162 163 get isolationKey() { 164 return this.#connection?.isolationKey; 165 } 166 167 get hasValidProxyPass() { 168 return !!this.#pass?.isValid(); 169 } 170 171 createChannelFilter() { 172 if (!this.#connection) { 173 this.#connection = lazy.IPPChannelFilter.create(); 174 this.#connection.start(); 175 } 176 } 177 178 cancelChannelFilter() { 179 if (this.#connection) { 180 this.#connection.stop(); 181 this.#connection = null; 182 } 183 } 184 185 get state() { 186 return this.#state; 187 } 188 189 /** 190 * Start the proxy if the user is eligible. 191 * 192 * @param {boolean} userAction 193 * True if started by user action, false if system action 194 */ 195 async start(userAction = true) { 196 if (this.#state === IPPProxyStates.NOT_READY) { 197 throw new Error("This method should not be called when not ready"); 198 } 199 200 if (this.#state === IPPProxyStates.ACTIVATING) { 201 if (!this.#activatingPromise) { 202 throw new Error("Activating without a promise?!?"); 203 } 204 205 return this.#activatingPromise; 206 } 207 208 const activating = async () => { 209 let started = false; 210 try { 211 started = await this.#startInternal(); 212 } catch (error) { 213 this.#setErrorState(ERRORS.GENERIC, error); 214 this.cancelChannelFilter(); 215 return; 216 } 217 218 if (this.#state === IPPProxyStates.ERROR) { 219 return; 220 } 221 222 // Proxy failed to start but no error was given. 223 if (!started) { 224 this.#setState(IPPProxyStates.READY); 225 return; 226 } 227 228 this.#setState(IPPProxyStates.ACTIVE); 229 230 Glean.ipprotection.toggled.record({ 231 userAction, 232 enabled: true, 233 }); 234 235 if (userAction) { 236 this.#reloadCurrentTab(); 237 } 238 }; 239 240 this.#setState(IPPProxyStates.ACTIVATING); 241 this.#activatingPromise = activating().finally( 242 () => (this.#activatingPromise = null) 243 ); 244 return this.#activatingPromise; 245 } 246 247 async #startInternal() { 248 await lazy.IPProtectionServerlist.maybeFetchList(); 249 250 const enrollAndEntitleData = 251 await lazy.IPPEnrollAndEntitleManager.maybeEnrollAndEntitle(); 252 if (!enrollAndEntitleData || !enrollAndEntitleData.isEnrolledAndEntitled) { 253 this.#setErrorState(enrollAndEntitleData.error || ERRORS.GENERIC); 254 return false; 255 } 256 257 if (lazy.IPProtectionService.state !== lazy.IPProtectionStates.READY) { 258 this.#setErrorState(ERRORS.GENERIC); 259 return false; 260 } 261 262 // Retry getting state if the previous attempt failed. 263 if (this.#state === IPPProxyStates.ERROR) { 264 this.updateState(); 265 } 266 267 this.errors = []; 268 269 this.createChannelFilter(); 270 271 // If the current proxy pass is valid, no need to re-authenticate. 272 // Throws an error if the proxy pass is not available. 273 if (this.#pass == null || this.#pass.shouldRotate()) { 274 this.#pass = await this.#getProxyPass(); 275 } 276 this.#schedulePassRotation(this.#pass); 277 278 const location = lazy.IPProtectionServerlist.getDefaultLocation(); 279 const server = lazy.IPProtectionServerlist.selectServer(location?.city); 280 if (!server) { 281 this.#setErrorState(ERRORS.GENERIC, "No server found"); 282 return false; 283 } 284 285 lazy.logConsole.debug("Server:", server?.hostname); 286 287 this.#connection.initialize(this.#pass.asBearerToken(), server); 288 289 this.usageObserver.start(); 290 this.usageObserver.addIsolationKey(this.#connection.isolationKey); 291 292 this.networkErrorObserver.start(); 293 this.networkErrorObserver.addIsolationKey(this.#connection.isolationKey); 294 295 lazy.logConsole.info("Started"); 296 297 if (!!this.#connection?.active && !!this.#connection?.proxyInfo) { 298 this.#activatedAt = ChromeUtils.now(); 299 return true; 300 } 301 302 return false; 303 } 304 305 /** 306 * Stops the proxy. 307 * 308 * @param {boolean} userAction 309 * True if started by user action, false if system action 310 */ 311 async stop(userAction = true) { 312 if (this.#state === IPPProxyStates.ACTIVATING) { 313 if (!this.#activatingPromise) { 314 throw new Error("Activating without a promise?!?"); 315 } 316 317 await this.#activatingPromise.then(() => this.stop(userAction)); 318 return; 319 } 320 321 if (this.#state !== IPPProxyStates.ACTIVE) { 322 return; 323 } 324 325 this.cancelChannelFilter(); 326 327 lazy.clearTimeout(this.#rotationTimer); 328 this.#rotationTimer = 0; 329 330 this.networkErrorObserver.stop(); 331 332 lazy.logConsole.info("Stopped"); 333 334 const sessionLength = ChromeUtils.now() - this.#activatedAt; 335 336 Glean.ipprotection.toggled.record({ 337 userAction, 338 duration: sessionLength, 339 enabled: false, 340 }); 341 342 this.#setState(IPPProxyStates.READY); 343 344 if (userAction) { 345 this.#reloadCurrentTab(); 346 } 347 } 348 349 /** 350 * Gets the current window and reloads the selected tab. 351 */ 352 #reloadCurrentTab() { 353 let win = Services.wm.getMostRecentBrowserWindow(); 354 if (win) { 355 win.gBrowser.reloadTab(win.gBrowser.selectedTab); 356 } 357 } 358 359 /** 360 * Stop any connections and reset the pass if the user has changed. 361 */ 362 async reset() { 363 this.#pass = null; 364 if ( 365 this.#state === IPPProxyStates.ACTIVE || 366 this.#state === IPPProxyStates.ACTIVATING 367 ) { 368 await this.stop(); 369 } 370 } 371 372 #handleEvent(_event) { 373 this.updateState(); 374 } 375 376 /** 377 * Fetches a new ProxyPass. 378 * Throws an error on failures. 379 * 380 * @returns {Promise<ProxyPass|Error>} - the proxy pass if it available. 381 */ 382 async #getProxyPass() { 383 let { status, error, pass } = 384 await lazy.IPProtectionService.guardian.fetchProxyPass(); 385 lazy.logConsole.debug("ProxyPass:", { 386 status, 387 valid: pass?.isValid(), 388 error, 389 }); 390 391 if (error || !pass || status != 200) { 392 throw error || new Error(`Status: ${status}`); 393 } 394 395 return pass; 396 } 397 398 /** 399 * Given a ProxyPass, sets a timer and triggers a rotation when it's about to expire. 400 * 401 * @param {*} pass 402 */ 403 #schedulePassRotation(pass) { 404 if (this.#rotationTimer) { 405 lazy.clearTimeout(this.#rotationTimer); 406 this.#rotationTimer = 0; 407 } 408 409 const now = Temporal.Now.instant(); 410 const rotationTimePoint = pass.rotationTimePoint; 411 let msUntilRotation = now.until(rotationTimePoint).total("milliseconds"); 412 if (msUntilRotation <= 0) { 413 msUntilRotation = 0; 414 } 415 416 lazy.logConsole.debug( 417 `ProxyPass will rotate in ${now.until(rotationTimePoint).total("minutes")} minutes` 418 ); 419 this.#rotationTimer = lazy.setTimeout(async () => { 420 this.#rotationTimer = 0; 421 if (!this.#connection?.active) { 422 return; 423 } 424 lazy.logConsole.debug(`Statrting scheduled ProxyPass rotation`); 425 await this.#rotateProxyPass(); 426 }, msUntilRotation); 427 } 428 429 /** 430 * Starts a flow to get a new ProxyPass and replace the current one. 431 * 432 * @returns {Promise<void>} - Returns a promise that resolves when the rotation is complete or failed. 433 * When it's called again while a rotation is in progress, it will return the existing promise. 434 */ 435 async #rotateProxyPass() { 436 if (this.#rotateProxyPassPromise) { 437 return this.#rotateProxyPassPromise; 438 } 439 this.#rotateProxyPassPromise = this.#getProxyPass(); 440 const pass = await this.#rotateProxyPassPromise; 441 this.#rotateProxyPassPromise = null; 442 if (!pass) { 443 return null; 444 } 445 // Inject the new token in the current connection 446 if (this.#connection?.active) { 447 this.#connection.replaceAuthToken(pass.asBearerToken()); 448 this.usageObserver.addIsolationKey(this.#connection.isolationKey); 449 this.networkErrorObserver.addIsolationKey(this.#connection.isolationKey); 450 } 451 lazy.logConsole.debug("Successfully rotated token!"); 452 this.#pass = pass; 453 this.#schedulePassRotation(pass); 454 return null; 455 } 456 457 #handleProxyErrorEvent(event) { 458 if (!this.#connection?.active) { 459 return null; 460 } 461 const { isolationKey, level, httpStatus } = event.detail; 462 if (isolationKey != this.#connection?.isolationKey) { 463 // This error does not concern our current connection. 464 // This could be due to an old request after a token refresh. 465 return null; 466 } 467 468 if (httpStatus !== 401) { 469 // Envoy returns a 401 if the token is rejected 470 // So for now as we only care about rotating tokens we can exit here. 471 return null; 472 } 473 474 if (level == "error" || this.#pass?.shouldRotate()) { 475 // If this is a visible top-level error force a rotation 476 return this.#rotateProxyPass(); 477 } 478 return null; 479 } 480 481 updateState() { 482 this.stop(false); 483 this.reset(); 484 485 if (lazy.IPProtectionService.state === lazy.IPProtectionStates.READY) { 486 this.#setState(IPPProxyStates.READY); 487 return; 488 } 489 490 this.#setState(IPPProxyStates.NOT_READY); 491 } 492 493 /** 494 * Helper to dispatch error messages. 495 * 496 * @param {string} error - the error message to send. 497 * @param {string} [errorContext] - the error message to log. 498 */ 499 #setErrorState(error, errorContext) { 500 this.errors.push(error); 501 502 if (this.errors.length > MAX_ERROR_HISTORY) { 503 this.errors.splice(0, this.errors.length - MAX_ERROR_HISTORY); 504 } 505 506 this.#setState(IPPProxyStates.ERROR); 507 lazy.logConsole.error(errorContext || error); 508 Glean.ipprotection.error.record({ source: "ProxyManager" }); 509 } 510 511 #setState(state) { 512 if (state === this.#state) { 513 return; 514 } 515 516 this.#state = state; 517 518 this.dispatchEvent( 519 new CustomEvent("IPPProxyManager:StateChanged", { 520 bubbles: true, 521 composed: true, 522 detail: { 523 state, 524 }, 525 }) 526 ); 527 } 528 } 529 530 const IPPProxyManager = new IPPProxyManagerSingleton(); 531 532 export { IPPProxyManager };