This is an automated email from the git hooks/post-receive script.
richard pushed a commit to branch tor-browser-102.5.0esr-12.0-2 in repository tor-browser.
commit 2916e169c4ffea3de85bd259cdaca22f6f50a18d Author: Pier Angelo Vendrame pierov@torproject.org AuthorDate: Mon Oct 10 15:13:04 2022 +0200
Bug 40933: Add tor-launcher functionality --- browser/installer/package-manifest.in | 1 + toolkit/components/moz.build | 1 + .../tor-launcher/TorBootstrapRequest.jsm | 129 ++++ .../components/tor-launcher/TorLauncherUtil.jsm | 569 ++++++++++++++++ .../components/tor-launcher/TorMonitorService.jsm | 506 ++++++++++++++ toolkit/components/tor-launcher/TorParsers.jsm | 275 ++++++++ toolkit/components/tor-launcher/TorProcess.jsm | 535 +++++++++++++++ .../components/tor-launcher/TorProtocolService.jsm | 752 +++++++++++++++++++++ .../components/tor-launcher/TorStartupService.jsm | 70 ++ toolkit/components/tor-launcher/components.conf | 10 + toolkit/components/tor-launcher/moz.build | 17 + .../components/tor-launcher/tor-launcher.manifest | 1 + 12 files changed, 2866 insertions(+)
diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in index bc0803aef688..cb7891d5b43e 100644 --- a/browser/installer/package-manifest.in +++ b/browser/installer/package-manifest.in @@ -234,6 +234,7 @@ @RESPATH@/browser/chrome/browser.manifest @RESPATH@/chrome/pdfjs.manifest @RESPATH@/chrome/pdfjs/* +@RESPATH@/components/tor-launcher.manifest @RESPATH@/chrome/toolkit@JAREXT@ @RESPATH@/chrome/toolkit.manifest #ifdef MOZ_GTK diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build index 86a289d2c71d..b405fe52eb79 100644 --- a/toolkit/components/moz.build +++ b/toolkit/components/moz.build @@ -75,6 +75,7 @@ DIRS += [ "thumbnails", "timermanager", "tooltiptext", + "tor-launcher", "typeaheadfind", "utils", "url-classifier", diff --git a/toolkit/components/tor-launcher/TorBootstrapRequest.jsm b/toolkit/components/tor-launcher/TorBootstrapRequest.jsm new file mode 100644 index 000000000000..e999d5c3f62c --- /dev/null +++ b/toolkit/components/tor-launcher/TorBootstrapRequest.jsm @@ -0,0 +1,129 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["TorBootstrapRequest", "TorTopics"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { setTimeout, clearTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" +); + +const { TorProtocolService } = ChromeUtils.import( + "resource://gre/modules/TorProtocolService.jsm" +); +const { TorLauncherUtil } = ChromeUtils.import( + "resource://gre/modules/TorLauncherUtil.jsm" +); + +/* tor-launcher observer topics */ +const TorTopics = Object.freeze({ + BootstrapStatus: "TorBootstrapStatus", + BootstrapError: "TorBootstrapError", + LogHasWarnOrErr: "TorLogHasWarnOrErr", +}); + +// modeled after XMLHttpRequest +// nicely encapsulates the observer register/unregister logic +class TorBootstrapRequest { + constructor() { + // number of ms to wait before we abandon the bootstrap attempt + // a value of 0 implies we never wait + this.timeout = 0; + // callbacks for bootstrap process status updates + this.onbootstrapstatus = (progress, status) => {}; + this.onbootstrapcomplete = () => {}; + this.onbootstraperror = (message, details) => {}; + + // internal resolve() method for bootstrap + this._bootstrapPromiseResolve = null; + this._bootstrapPromise = null; + this._timeoutID = null; + } + + observe(subject, topic, data) { + const obj = subject?.wrappedJSObject; + switch (topic) { + case TorTopics.BootstrapStatus: { + const progress = obj.PROGRESS; + const status = TorLauncherUtil.getLocalizedBootstrapStatus(obj, "TAG"); + if (this.onbootstrapstatus) { + this.onbootstrapstatus(progress, status); + } + if (progress === 100) { + if (this.onbootstrapcomplete) { + this.onbootstrapcomplete(); + } + this._bootstrapPromiseResolve(true); + clearTimeout(this._timeoutID); + } + + break; + } + case TorTopics.BootstrapError: { + console.info("TorBootstrapRequest: observerd TorBootstrapError", obj); + this._stop(obj?.message, obj?.details); + break; + } + } + } + + // resolves 'true' if bootstrap succeeds, false otherwise + bootstrap() { + if (this._bootstrapPromise) { + return this._bootstrapPromise; + } + + this._bootstrapPromise = new Promise((resolve, reject) => { + this._bootstrapPromiseResolve = resolve; + + // register ourselves to listen for bootstrap events + Services.obs.addObserver(this, TorTopics.BootstrapStatus); + Services.obs.addObserver(this, TorTopics.BootstrapError); + + // optionally cancel bootstrap after a given timeout + if (this.timeout > 0) { + this._timeoutID = setTimeout(async () => { + this._timeoutID = null; + // TODO: Translate, if really used + await this._stop( + "Tor Bootstrap process timed out", + `Bootstrap attempt abandoned after waiting ${this.timeout} ms` + ); + }, this.timeout); + } + + // wait for bootstrapping to begin and maybe handle error + TorProtocolService.connect().catch(err => { + this._stop(err.message, ""); + }); + }).finally(() => { + // and remove ourselves once bootstrap is resolved + Services.obs.removeObserver(this, TorTopics.BootstrapStatus); + Services.obs.removeObserver(this, TorTopics.BootstrapError); + this._bootstrapPromise = null; + }); + + return this._bootstrapPromise; + } + + async cancel() { + await this._stop(); + } + + // Internal implementation. Do not use directly, but call cancel, instead. + async _stop(message, details) { + // first stop our bootstrap timeout before handling the error + if (this._timeoutID !== null) { + clearTimeout(this._timeoutID); + this._timeoutID = null; + } + + // stopBootstrap never throws + await TorProtocolService.stopBootstrap(); + + if (this.onbootstraperror && message) { + this.onbootstraperror(message, details); + } + + this._bootstrapPromiseResolve(false); + } +} diff --git a/toolkit/components/tor-launcher/TorLauncherUtil.jsm b/toolkit/components/tor-launcher/TorLauncherUtil.jsm new file mode 100644 index 000000000000..668d94669e5b --- /dev/null +++ b/toolkit/components/tor-launcher/TorLauncherUtil.jsm @@ -0,0 +1,569 @@ +// Copyright (c) 2022, The Tor Project, Inc. +// See LICENSE for licensing information. + +"use strict"; + +/************************************************************************* + * Tor Launcher Util JS Module + *************************************************************************/ + +var EXPORTED_SYMBOLS = ["TorLauncherUtil"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const kPropBundleURI = "chrome://torbutton/locale/torlauncher.properties"; +const kPropNamePrefix = "torlauncher."; +const kIPCDirPrefName = "extensions.torlauncher.tmp_ipc_dir"; + +let gStringBundle = null; + +class TorFile { + // The nsIFile to be returned + file = null; + + // A relative or absolute path that will determine file + path = null; + pathIsRelative = false; + // If true, path is ignored + useAppDir = false; + + isIPC = false; + checkIPCPathLen = true; + + static _isFirstIPCPathRequest = true; + static _isUserDataOutsideOfAppDir = undefined; + static _dataDir = null; + static _appDir = null; + + constructor(aTorFileType, aCreate) { + this.fileType = aTorFileType; + + this.getFromPref(); + this.getIPC(); + // No preference and no pre-determined IPC path: use a default path. + if (!this.file && !this.path) { + this.getDefault(); + } + + if (!this.file && this.path) { + this.pathToFile(); + } + if (this.file && !this.file.exists() && !this.isIPC && aCreate) { + this.createFile(); + } + this.normalize(); + } + + getFile() { + return this.file; + } + + getFromPref() { + const prefName = `extensions.torlauncher.${this.fileType}_path`; + this.path = Services.prefs.getCharPref(prefName, ""); + if (this.path) { + const re = TorLauncherUtil.isWindows ? /^[A-Za-z]:\/ : /^//; + this.isRelativePath = !re.test(this.path); + // always try to use path if provided in pref + this.checkIPCPathLen = false; + } + } + + getIPC() { + const isControlIPC = this.fileType === "control_ipc"; + const isSOCKSIPC = this.fileType === "socks_ipc"; + this.isIPC = isControlIPC || isSOCKSIPC; + + const kControlIPCFileName = "control.socket"; + const kSOCKSIPCFileName = "socks.socket"; + this.ipcFileName = isControlIPC ? kControlIPCFileName : kSOCKSIPCFileName; + this.extraIPCPathLen = this.isSOCKSIPC ? 2 : 0; + + // Do not do anything else if this.path has already been populated with the + // _path preference for this file type (or if we are not looking for an IPC + // file). + if (this.path || !this.isIPC) { + return; + } + + // If this is the first request for an IPC path during this browser + // session, remove the old temporary directory. This helps to keep /tmp + // clean if the browser crashes or is killed. + if (TorFile._isFirstIPCPathRequest) { + TorLauncherUtil.cleanupTempDirectories(); + TorFile._isFirstIPCPathRequest = false; + } else { + // FIXME: Do we really need a preference? Or can we save it in a static + // member? + // Retrieve path for IPC objects (it may have already been determined). + const ipcDirPath = Services.prefs.getCharPref(kIPCDirPrefName, ""); + if (ipcDirPath) { + // We have already determined where IPC objects will be placed. + this.file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + this.file.initWithPath(ipcDirPath); + this.file.append(this.ipcFileName); + this.checkIPCPathLen = false; // already checked. + return; + } + } + + // If XDG_RUNTIME_DIR is set, use it as the base directory for IPC + // objects (e.g., Unix domain sockets) -- assuming it is not too long. + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (!env.exists("XDG_RUNTIME_DIR")) { + return; + } + const ipcDir = this.createUniqueIPCDir(env.get("XDG_RUNTIME_DIR")); + if (ipcDir) { + const f = ipcDir.clone(); + f.append(this.ipcFileName); + if (this.isIPCPathLengthOK(f.path, this.extraIPCPathLen)) { + this.file = f; + this.checkIPCPathLen = false; // no need to check again. + + // Store directory path so it can be reused for other IPC objects + // and so it can be removed during exit. + Services.prefs.setCharPref(kIPCDirPrefName, ipcDir.path); + } else { + // too long; remove the directory that we just created. + ipcDir.remove(false); + } + } + } + + // This block is used for the TorBrowser-Data/ case. + getDefault() { + let torPath = ""; + let dataDir = ""; + // FIXME: TOR_BROWSER_DATA_OUTSIDE_APP_DIR is used only on macOS at the + // moment. In Linux and Windows it might not work anymore. + // We might simplify the code here, if we get rid of this macro. + // Also, we allow specifying directly a relative path, for a portable mode. + // Anyway, that macro is also available in AppConstants. + if (TorFile.isUserDataOutsideOfAppDir) { + if (TorLauncherUtil.isMac) { + torPath = "Contents/Resources/"; + } + torPath += "TorBrowser/Tor"; + } else { + torPath = "Tor"; + dataDir = "Data/"; + } + + switch (this.fileType) { + case "tor": + if (TorLauncherUtil.isMac) { + this.path = `${torPath}/tor`; + } else { + this.path = + torPath + "/tor" + (TorLauncherUtil.isWindows ? ".exe" : ""); + } + break; + case "torrc-defaults": + this.path = TorFile.isUserDataOutsideOfAppDir + ? `${torPath}/torrc-defaults` + : `${dataDir}Tor/torrc-defaults`; + break; + case "torrc": + this.path = `${dataDir}Tor/torrc`; + break; + case "tordatadir": + this.path = `${dataDir}Tor`; + break; + case "toronionauthdir": + this.path = `${dataDir}Tor/onion-auth`; + break; + case "pt-profiles-dir": + this.path = TorFile.isUserDataOutsideOfAppDir + ? "Tor/PluggableTransports" + : `${dataDir}Browser`; + break; + case "pt-startup-dir": + if (TorLauncherUtil.isMac && TorFile.isUserDataOutsideOfAppDir) { + this.path = "Contents/MacOS/Tor"; + } else { + this.file = TorFile.appDir.clone(); + return; + } + break; + default: + if (!TorLauncherUtil.isWindows && this.isIPC) { + this.path = "Tor/" + this.ipcFileName; + break; + } + throw new Error("Unknown file type"); + } + if (TorLauncherUtil.isWindows) { + this.path = this.path.replaceAll("/", "\"); + } + this.isRelativePath = true; + } + + pathToFile() { + if (TorLauncherUtil.isWindows) { + this.path = this.path.replaceAll("/", "\"); + } + // Turn 'path' into an absolute path when needed. + if (this.isRelativePath) { + const isUserData = + this.fileType !== "tor" && + this.fileType !== "pt-startup-dir" && + this.fileType !== "torrc-defaults"; + if (TorFile.isUserDataOutsideOfAppDir) { + let baseDir = isUserData ? TorFile.dataDir : TorFile.appDir; + this.file = baseDir.clone(); + } else { + this.file = TorFile.appDir.clone(); + this.file.append("TorBrowser"); + } + this.file.appendRelativePath(this.path); + } else { + this.file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + this.file.initWithPath(this.path); + } + } + + createFile() { + if ( + "tordatadir" == this.fileType || + "toronionauthdir" == this.fileType || + "pt-profiles-dir" == this.fileType + ) { + this.file.create(this.file.DIRECTORY_TYPE, 0o700); + } else { + this.file.create(this.file.NORMAL_FILE_TYPE, 0o600); + } + } + + // If the file exists or an IPC object was requested, normalize the path + // and return a file object. The control and SOCKS IPC objects will be + // created by tor. + normalize() { + if (!this.file.exists() && !this.isIPC) { + throw new Error(`${this.fileType} file not found: ${this.file.path}`); + } + try { + this.file.normalize(); + } catch (e) { + console.warn("Normalization of the path failed", e); + } + + // Ensure that the IPC path length is short enough for use by the + // operating system. If not, create and use a unique directory under + // /tmp for all IPC objects. The created directory path is stored in + // a preference so it can be reused for other IPC objects and so it + // can be removed during exit. + if ( + this.isIPC && + this.checkIPCPathLen && + !this.isIPCPathLengthOK(this.file.path, this.extraIPCPathLen) + ) { + this.file = this.createUniqueIPCDir("/tmp"); + if (!this.file) { + throw new Error("failed to create unique directory under /tmp"); + } + + Services.prefs.setCharPref(kIPCDirPrefName, this.file.path); + this.file.append(this.ipcFileName); + } + } + + // Return true if aPath is short enough to be used as an IPC object path, + // e.g., for a Unix domain socket path. aExtraLen is the "delta" necessary + // to accommodate other IPC objects that have longer names; it is used to + // account for "control.socket" vs. "socks.socket" (we want to ensure that + // all IPC objects are placed in the same parent directory unless the user + // has set prefs or env vars to explicitly specify the path for an object). + // We enforce a maximum length of 100 because all operating systems allow + // at least 100 characters for Unix domain socket paths. + isIPCPathLengthOK(aPath, aExtraLen) { + const kMaxIPCPathLen = 100; + return aPath && aPath.length + aExtraLen <= kMaxIPCPathLen; + } + + // Returns an nsIFile or null if a unique directory could not be created. + createUniqueIPCDir(aBasePath) { + try { + const d = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + d.initWithPath(aBasePath); + d.append("Tor"); + d.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o700); + return d; + } catch (e) { + console.error(`createUniqueIPCDir failed for ${aBasePath}: `, e); + return null; + } + } + + static get isUserDataOutsideOfAppDir() { + if (this._isUserDataOutsideOfAppDir === undefined) { + // Determine if we are using a "side-by-side" data model by checking + // whether the user profile is outside of the app directory. + try { + const profDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + this._isUserDataOutsideOfAppDir = !this.appDir.contains(profDir); + } catch (e) { + this._isUserDataOutsideOfAppDir = false; + } + } + return this._isUserDataOutsideOfAppDir; + } + + // Returns an nsIFile that points to the application directory. + static get appDir() { + if (!this._appDir) { + let topDir = Services.dirsvc.get("CurProcD", Ci.nsIFile); + // On Linux and Windows, we want to return the Browser/ directory. + // Because topDir ("CurProcD") points to Browser/browser on those + // platforms, we need to go up one level. + // On Mac OS, we want to return the TorBrowser.app/ directory. + // Because topDir points to Contents/Resources/browser on Mac OS, + // we need to go up 3 levels. + let tbbBrowserDepth = TorLauncherUtil.isMac ? 3 : 1; + while (tbbBrowserDepth > 0) { + let didRemove = topDir.leafName != "."; + topDir = topDir.parent; + if (didRemove) { + tbbBrowserDepth--; + } + } + this._appDir = topDir; + } + return this._appDir; + } + + // Returns an nsIFile that points to the TorBrowser-Data/ directory. + // This function is only used when isUserDataOutsideOfAppDir === true. + // May throw. + static get dataDir() { + if (!this._dataDir) { + const profDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + this._dataDir = profDir.parent.parent; + } + return this._dataDir; + } +} + +const TorLauncherUtil = Object.freeze({ + get isMac() { + return Services.appinfo.OS === "Darwin"; + }, + + get isWindows() { + return Services.appinfo.OS === "WINNT"; + }, + + // Returns true if user confirms; false if not. + showConfirm(aParentWindow, aMsg, aDefaultButtonLabel, aCancelButtonLabel) { + if (!aParentWindow) { + aParentWindow = Services.wm.getMostRecentWindow("navigator:browser"); + } + + const ps = Services.prompt; + const title = this.getLocalizedString("error_title"); + const btnFlags = + ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + + ps.BUTTON_POS_0_DEFAULT + + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING; + + const notUsed = { value: false }; + const btnIndex = ps.confirmEx( + aParentWindow, + title, + aMsg, + btnFlags, + aDefaultButtonLabel, + aCancelButtonLabel, + null, + null, + notUsed + ); + return btnIndex === 0; + }, + + // Localized Strings + // TODO: Switch to fluent also these ones. + + // "torlauncher." is prepended to aStringName. + getLocalizedString(aStringName) { + if (!aStringName) { + return aStringName; + } + try { + const key = kPropNamePrefix + aStringName; + return this._stringBundle.GetStringFromName(key); + } catch (e) {} + return aStringName; + }, + + // "torlauncher." is prepended to aStringName. + getFormattedLocalizedString(aStringName, aArray, aLen) { + if (!aStringName || !aArray) { + return aStringName; + } + try { + const key = kPropNamePrefix + aStringName; + return this._stringBundle.formatStringFromName(key, aArray, aLen); + } catch (e) {} + return aStringName; + }, + + getLocalizedStringForError(aNSResult) { + for (let prop in Cr) { + if (Cr[prop] === aNSResult) { + const key = "nsresult." + prop; + const rv = this.getLocalizedString(key); + if (rv !== key) { + return rv; + } + return prop; // As a fallback, return the NS_ERROR... name. + } + } + return undefined; + }, + + getLocalizedBootstrapStatus(aStatusObj, aKeyword) { + if (!aStatusObj || !aKeyword) { + return ""; + } + + let result; + let fallbackStr; + if (aStatusObj[aKeyword]) { + let val = aStatusObj[aKeyword].toLowerCase(); + let key; + if (aKeyword === "TAG") { + // The bootstrap status tags in tagMap below are used by Tor + // versions prior to 0.4.0.x. We map each one to the tag that will + // produce the localized string that is the best fit. + const tagMap = { + conn_dir: "conn", + handshake_dir: "onehop_create", + conn_or: "enough_dirinfo", + handshake_or: "ap_conn", + }; + if (val in tagMap) { + val = tagMap[val]; + } + + key = "bootstrapStatus." + val; + fallbackStr = aStatusObj.SUMMARY; + } else if (aKeyword === "REASON") { + if (val === "connectreset") { + val = "connectrefused"; + } + + key = "bootstrapWarning." + val; + fallbackStr = aStatusObj.WARNING; + } + + result = TorLauncherUtil.getLocalizedString(key); + if (result === key) { + result = undefined; + } + } + + if (!result) { + result = fallbackStr; + } + + if (aKeyword === "REASON" && aStatusObj.HOSTADDR) { + result += " - " + aStatusObj.HOSTADDR; + } + + return result ? result : ""; + }, + + get shouldStartAndOwnTor() { + const kPrefStartTor = "extensions.torlauncher.start_tor"; + try { + const kBrowserToolboxPort = "MOZ_BROWSER_TOOLBOX_PORT"; + const kEnvSkipLaunch = "TOR_SKIP_LAUNCH"; + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (env.exists(kBrowserToolboxPort)) { + return false; + } + if (env.exists(kEnvSkipLaunch)) { + const value = parseInt(env.get(kEnvSkipLaunch)); + return isNaN(value) || !value; + } + } catch (e) {} + return Services.prefs.getBoolPref(kPrefStartTor, true); + }, + + get shouldShowNetworkSettings() { + try { + const kEnvForceShowNetConfig = "TOR_FORCE_NET_CONFIG"; + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (env.exists(kEnvForceShowNetConfig)) { + const value = parseInt(env.get(kEnvForceShowNetConfig)); + return !isNaN(value) && value; + } + } catch (e) {} + return true; + }, + + get shouldOnlyConfigureTor() { + const kPrefOnlyConfigureTor = "extensions.torlauncher.only_configure_tor"; + try { + const kEnvOnlyConfigureTor = "TOR_CONFIGURE_ONLY"; + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (env.exists(kEnvOnlyConfigureTor)) { + const value = parseInt(env.get(kEnvOnlyConfigureTor)); + return !isNaN(value) && value; + } + } catch (e) {} + return Services.prefs.getBoolPref(kPrefOnlyConfigureTor, false); + }, + + // Returns an nsIFile. + // If aTorFileType is "control_ipc" or "socks_ipc", aCreate is ignored + // and there is no requirement that the IPC object exists. + // For all other file types, null is returned if the file does not exist + // and it cannot be created (it will be created if aCreate is true). + getTorFile(aTorFileType, aCreate) { + if (!aTorFileType) { + return null; + } + try { + const torFile = new TorFile(aTorFileType, aCreate); + return torFile.getFile(); + } catch (e) { + console.error(`getTorFile: cannot get ${aTorFileType}`, e); + } + return null; // File not found or error (logged above). + }, + + cleanupTempDirectories() { + const dirPath = Services.prefs.getCharPref(kIPCDirPrefName, ""); + try { + Services.prefs.clearUserPref(kIPCDirPrefName); + } catch (e) {} + try { + if (dirPath) { + const f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + f.initWithPath(dirPath); + if (f.exists()) { + f.remove(false); + } + } + } catch (e) { + console.warn("Could not remove the IPC directory", e); + } + }, + + get _stringBundle() { + if (!gStringBundle) { + gStringBundle = Services.strings.createBundle(kPropBundleURI); + } + return gStringBundle; + }, +}); diff --git a/toolkit/components/tor-launcher/TorMonitorService.jsm b/toolkit/components/tor-launcher/TorMonitorService.jsm new file mode 100644 index 000000000000..5a8f1e77b909 --- /dev/null +++ b/toolkit/components/tor-launcher/TorMonitorService.jsm @@ -0,0 +1,506 @@ +// Copyright (c) 2022, The Tor Project, Inc. + +"use strict"; + +var EXPORTED_SYMBOLS = ["TorMonitorService"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { clearTimeout, setTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const { TorParsers, TorStatuses } = ChromeUtils.import( + "resource://gre/modules/TorParsers.jsm" +); +const { TorProcess } = ChromeUtils.import( + "resource://gre/modules/TorProcess.jsm" +); + +const { TorLauncherUtil } = ChromeUtils.import( + "resource://gre/modules/TorLauncherUtil.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "controller", + "resource://torbutton/modules/tor-control-port.js" +); + +// TODO: Write a helper to create these logs +XPCOMUtils.defineLazyGetter(this, "logger", () => { + const { ConsoleAPI } = ChromeUtils.import( + "resource://gre/modules/Console.jsm" + ); + // TODO: Use a preference to set the log level. + const consoleOptions = { + // maxLogLevel: "warn", + maxLogLevel: "all", + prefix: "TorMonitorService", + }; + return new ConsoleAPI(consoleOptions); +}); + +const Preferences = Object.freeze({ + PromptAtStartup: "extensions.torlauncher.prompt_at_startup", +}); + +const TorTopics = Object.freeze({ + BootstrapError: "TorBootstrapError", + HasWarnOrErr: "TorLogHasWarnOrErr", + ProcessExited: "TorProcessExited", + ProcessIsReady: "TorProcessIsReady", + ProcessRestarted: "TorProcessRestarted", +}); + +const ControlConnTimings = Object.freeze({ + initialDelayMS: 25, // Wait 25ms after the process has started, before trying to connect + maxRetryMS: 10000, // Retry at most every 10 seconds + timeoutMS: 5 * 60 * 1000, // Wait at most 5 minutes for tor to start +}); + +/** + * This service monitors an existing Tor instance, or starts one, if needed, and + * then starts monitoring it. + * + * This is the service which should be queried to know information about the + * status of the bootstrap, the logs, etc... + */ +const TorMonitorService = { + _connection: null, + _eventsToMonitor: Object.freeze(["STATUS_CLIENT", "NOTICE", "WARN", "ERR"]), + _torLog: [], // Array of objects with date, type, and msg properties. + _startTimeout: null, + + _isBootstrapDone: false, + _bootstrapErrorOccurred: false, + _lastWarningPhase: null, + _lastWarningReason: null, + + _torProcess: null, + + _inited: false, + + // Public methods + + // Starts Tor, if needed, and starts monitoring for events + init() { + if (this._inited) { + return; + } + this._inited = true; + if (this.ownsTorDaemon) { + this._controlTor(); + } else { + logger.info( + "Not starting the event monitor, as we do not own the Tor daemon." + ); + } + logger.debug("TorMonitorService initialized"); + }, + + // Closes the connection that monitors for events. + // When Tor is started by Tor Browser, it is configured to exit when the + // control connection is closed. Therefore, as a matter of facts, calling this + // function also makes the child Tor instance stop. + uninit() { + if (this._torProcess) { + this._torProcess.forget(); + this._torProcess.onExit = null; + this._torProcess.onRestart = null; + this._torProcess = null; + } + this._shutDownEventMonitor(); + }, + + async retrieveBootstrapStatus() { + if (!this._connection) { + throw new Error("Event monitor connection not available"); + } + + // TODO: Unify with TorProtocolService.sendCommand and put everything in the + // reviewed torbutton replacement. + const cmd = "GETINFO"; + const key = "status/bootstrap-phase"; + let reply = await this._connection.sendCommand(`${cmd} ${key}`); + if (!reply) { + throw new Error("We received an empty reply"); + } + // A typical reply looks like: + // 250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY="Done" + // 250 OK + reply = TorParsers.parseCommandResponse(reply); + if (!TorParsers.commandSucceeded(reply)) { + throw new Error(`${cmd} failed`); + } + reply = TorParsers.parseReply(cmd, key, reply); + if (reply.lineArray) { + this._processBootstrapStatus(reply.lineArray[0], true); + } + }, + + // Returns captured log message as a text string (one message per line). + getLog() { + return this._torLog + .map(logObj => { + const timeStr = logObj.date + .toISOString() + .replace("T", " ") + .replace("Z", ""); + return `${timeStr} [${logObj.type}] ${logObj.msg}`; + }) + .join(TorLauncherUtil.isWindows ? "\r\n" : "\n"); + }, + + // true if we launched and control tor, false if using system tor + get ownsTorDaemon() { + return TorLauncherUtil.shouldStartAndOwnTor; + }, + + get isBootstrapDone() { + return this._isBootstrapDone; + }, + + get bootstrapErrorOccurred() { + return this._bootstrapErrorOccurred; + }, + + clearBootstrapError() { + this._bootstrapErrorOccurred = false; + this._lastWarningPhase = null; + this._lastWarningReason = null; + }, + + // This should be used for debug only + setBootstrapError() { + this._bootstrapErrorOccurred = true; + }, + + get isRunning() { + return !!this._connection; + }, + + // Private methods + + async _startProcess() { + // TorProcess should be instanced once, then always reused and restarted + // only through the prompt it exposes when the controlled process dies. + if (!this._torProcess) { + this._torProcess = new TorProcess(); + this._torProcess.onExit = () => { + this._shutDownEventMonitor(); + Services.obs.notifyObservers(null, TorTopics.ProcessExited); + }; + this._torProcess.onRestart = async () => { + this._shutDownEventMonitor(); + await this._controlTor(); + Services.obs.notifyObservers(null, TorTopics.ProcessRestarted); + }; + } + + // Already running, but we did not start it + if (this._torProcess.isRunning) { + return false; + } + + try { + await this._torProcess.start(); + if (this._torProcess.isRunning) { + logger.info("tor started"); + } + } catch (e) { + // TorProcess already logs the error. + this._bootstrapErrorOccurred = true; + this._lastWarningPhase = "startup"; + this._lastWarningReason = e.toString(); + } + return this._torProcess.isRunning; + }, + + async _controlTor() { + if (!this._torProcess?.isRunning && !(await this._startProcess())) { + logger.error("Tor not running, not starting to monitor it."); + return; + } + + let delayMS = ControlConnTimings.initialDelayMS; + const callback = async () => { + if (await this._startEventMonitor()) { + this.retrieveBootstrapStatus().catch(e => { + logger.warn("Could not get the initial bootstrap status", e); + }); + + // FIXME: TorProcess is misleading here. We should use a topic related + // to having a control port connection, instead. + Services.obs.notifyObservers(null, TorTopics.ProcessIsReady); + logger.info(`Notified ${TorTopics.ProcessIsReady}`); + + // We reset this here hoping that _shutDownEventMonitor can interrupt + // the current monitor, either by calling clearTimeout and preventing it + // from starting, or by closing the control port connection. + if (this._startTimeout === null) { + logger.warn("Someone else reset _startTimeout!"); + } + this._startTimeout = null; + } else if ( + Date.now() - this._torProcessStartTime > + ControlConnTimings.timeoutMS + ) { + let s = TorLauncherUtil.getLocalizedString("tor_controlconn_failed"); + this._bootstrapErrorOccurred = true; + this._lastWarningPhase = "startup"; + this._lastWarningReason = s; + logger.info(s); + if (this._startTimeout === null) { + logger.warn("Someone else reset _startTimeout!"); + } + this._startTimeout = null; + } else { + delayMS *= 2; + if (delayMS > ControlConnTimings.maxRetryMS) { + delayMS = ControlConnTimings.maxRetryMS; + } + this._startTimeout = setTimeout(() => { + logger.debug(`Control port not ready, waiting ${delayMS / 1000}s.`); + callback(); + }, delayMS); + } + }; + // Check again, in the unfortunate case in which the execution was alrady + // queued, but was waiting network code. + if (this._startTimeout === null) { + this._startTimeout = setTimeout(callback, delayMS); + } else { + logger.error("Possible race? Refusing to start the timeout again"); + } + }, + + async _startEventMonitor() { + if (this._connection) { + return true; + } + + let conn; + try { + const avoidCache = true; + conn = await controller(avoidCache); + } catch (e) { + logger.error("Cannot open a control port connection", e); + if (conn) { + try { + conn.close(); + } catch (e) { + logger.error( + "Also, the connection is not null but cannot be closed", + e + ); + } + } + return false; + } + + // TODO: optionally monitor INFO and DEBUG log messages. + let reply = await conn.sendCommand( + "SETEVENTS " + this._eventsToMonitor.join(" ") + ); + reply = TorParsers.parseCommandResponse(reply); + if (!TorParsers.commandSucceeded(reply)) { + logger.error("SETEVENTS failed"); + conn.close(); + return false; + } + + // FIXME: At the moment it is not possible to start the event monitor + // when we do start the tor process. So, does it make sense to keep this + // control? + if (this._torProcess) { + this._torProcess.connectionWorked(); + } + + if (!TorLauncherUtil.shouldOnlyConfigureTor) { + try { + await this._takeTorOwnership(conn); + } catch (e) { + logger.warn("Could not take ownership of the Tor daemon", e); + } + } + + this._connection = conn; + this._waitForEventData(); + return true; + }, + + // Try to become the primary controller (TAKEOWNERSHIP). + async _takeTorOwnership(conn) { + const takeOwnership = "TAKEOWNERSHIP"; + let reply = await conn.sendCommand(takeOwnership); + reply = TorParsers.parseCommandResponse(reply); + if (!TorParsers.commandSucceeded(reply)) { + logger.warn("Take ownership failed"); + } else { + const resetConf = "RESETCONF __OwningControllerProcess"; + reply = await conn.sendCommand(resetConf); + reply = TorParsers.parseCommandResponse(reply); + if (!TorParsers.commandSucceeded(reply)) { + logger.warn("Clear owning controller process failed"); + } + } + }, + + _waitForEventData() { + if (!this._connection) { + return; + } + logger.debug("Start watching events:", this._eventsToMonitor); + let replyObj = {}; + for (const torEvent of this._eventsToMonitor) { + this._connection.watchEvent( + torEvent, + null, + line => { + if (!line) { + return; + } + logger.debug("Event response: ", line); + const isComplete = TorParsers.parseReplyLine(line, replyObj); + if (isComplete) { + this._processEventReply(replyObj); + replyObj = {}; + } + }, + true + ); + } + }, + + _processEventReply(aReply) { + if (aReply._parseError || !aReply.lineArray.length) { + return; + } + + if (aReply.statusCode !== TorStatuses.EventNotification) { + logger.warn("Unexpected event status code:", aReply.statusCode); + return; + } + + // TODO: do we need to handle multiple lines? + const s = aReply.lineArray[0]; + const idx = s.indexOf(" "); + if (idx === -1) { + return; + } + const eventType = s.substring(0, idx); + const msg = s.substring(idx + 1).trim(); + + if (eventType === "STATUS_CLIENT") { + this._processBootstrapStatus(msg, false); + return; + } else if (!this._eventsToMonitor.includes(eventType)) { + logger.debug(`Dropping unlistened event ${eventType}`); + return; + } + + if (eventType === "WARN" || eventType === "ERR") { + // Notify so that Copy Log can be enabled. + Services.obs.notifyObservers(null, TorTopics.HasWarnOrErr); + } + + const now = new Date(); + const maxEntries = Services.prefs.getIntPref( + "extensions.torlauncher.max_tor_log_entries", + 1000 + ); + if (maxEntries > 0 && this._torLog.length >= maxEntries) { + this._torLog.splice(0, 1); + } + this._torLog.push({ date: now, type: eventType, msg }); + const logString = `Tor ${eventType}: ${msg}`; + logger.info(logString); + }, + + // Process a bootstrap status to update the current state, and broadcast it + // to TorBootstrapStatus observers. + // If aSuppressErrors is true, errors are ignored. This is used when we + // are handling the response to a "GETINFO status/bootstrap-phase" command. + _processBootstrapStatus(aStatusMsg, aSuppressErrors) { + const statusObj = TorParsers.parseBootstrapStatus(aStatusMsg); + if (!statusObj) { + return; + } + + // Notify observers + statusObj.wrappedJSObject = statusObj; + Services.obs.notifyObservers(statusObj, "TorBootstrapStatus"); + + if (statusObj.PROGRESS === 100) { + this._isBootstrapDone = true; + this._bootstrapErrorOccurred = false; + try { + Services.prefs.setBoolPref(Preferences.PromptAtStartup, false); + } catch (e) { + logger.warn(`Cannot set ${Preferences.PromptAtStartup}`, e); + } + return; + } + + this._isBootstrapDone = false; + + if ( + statusObj.TYPE === "WARN" && + statusObj.RECOMMENDATION !== "ignore" && + !aSuppressErrors + ) { + this._notifyBootstrapError(statusObj); + } + }, + + _notifyBootstrapError(statusObj) { + this._bootstrapErrorOccurred = true; + try { + Services.prefs.setBoolPref(Preferences.PromptAtStartup, true); + } catch (e) { + logger.warn(`Cannot set ${Preferences.PromptAtStartup}`, e); + } + const phase = TorLauncherUtil.getLocalizedBootstrapStatus(statusObj, "TAG"); + const reason = TorLauncherUtil.getLocalizedBootstrapStatus( + statusObj, + "REASON" + ); + const details = TorLauncherUtil.getFormattedLocalizedString( + "tor_bootstrap_failed_details", + [phase, reason], + 2 + ); + logger.error( + `Tor bootstrap error: [${statusObj.TAG}/${statusObj.REASON}] ${details}` + ); + + if ( + statusObj.TAG !== this._lastWarningPhase || + statusObj.REASON !== this._lastWarningReason + ) { + this._lastWarningPhase = statusObj.TAG; + this._lastWarningReason = statusObj.REASON; + + const message = TorLauncherUtil.getLocalizedString( + "tor_bootstrap_failed" + ); + Services.obs.notifyObservers( + { message, details }, + TorTopics.BootstrapError + ); + } + }, + + _shutDownEventMonitor() { + this._connection?.close(); + this._connection = null; + if (this._startTimeout !== null) { + clearTimeout(this._startTimeout); + this._startTimeout = null; + } + this._isBootstrapDone = false; + this.clearBootstrapError(); + }, +}; diff --git a/toolkit/components/tor-launcher/TorParsers.jsm b/toolkit/components/tor-launcher/TorParsers.jsm new file mode 100644 index 000000000000..1c206fc00e39 --- /dev/null +++ b/toolkit/components/tor-launcher/TorParsers.jsm @@ -0,0 +1,275 @@ +// Copyright (c) 2022, The Tor Project, Inc. + +"use strict"; + +var EXPORTED_SYMBOLS = ["TorParsers", "TorStatuses"]; + +const TorStatuses = Object.freeze({ + OK: 250, + EventNotification: 650, +}); + +const TorParsers = Object.freeze({ + commandSucceeded(aReply) { + return aReply?.statusCode === TorStatuses.OK; + }, + + // parseReply() understands simple GETCONF and GETINFO replies. + parseReply(aCmd, aKey, aReply) { + if (!aCmd || !aKey || !aReply) { + return []; + } + + const lcKey = aKey.toLowerCase(); + const prefix = lcKey + "="; + const prefixLen = prefix.length; + const tmpArray = []; + for (const line of aReply.lineArray) { + var lcLine = line.toLowerCase(); + if (lcLine === lcKey) { + tmpArray.push(""); + } else if (lcLine.indexOf(prefix) !== 0) { + console.warn(`Unexpected ${aCmd} response: ${line}`); + } else { + try { + let s = this.unescapeString(line.substring(prefixLen)); + tmpArray.push(s); + } catch (e) { + console.warn( + `Error while unescaping the response of ${aCmd}: ${line}`, + e + ); + } + } + } + + aReply.lineArray = tmpArray; + return aReply; + }, + + // Returns false if more lines are needed. The first time, callers + // should pass an empty aReplyObj. + // Parsing errors are indicated by aReplyObj._parseError = true. + parseReplyLine(aLine, aReplyObj) { + if (!aLine || !aReplyObj) { + return false; + } + + if (!("_parseError" in aReplyObj)) { + aReplyObj.statusCode = 0; + aReplyObj.lineArray = []; + aReplyObj._parseError = false; + } + + if (aLine.length < 4) { + console.error("Unexpected response: ", aLine); + aReplyObj._parseError = true; + return true; + } + + // TODO: handle + separators (data) + aReplyObj.statusCode = parseInt(aLine.substring(0, 3), 10); + const s = aLine.length < 5 ? "" : aLine.substring(4); + // Include all lines except simple "250 OK" ones. + if (aReplyObj.statusCode !== TorStatuses.OK || s !== "OK") { + aReplyObj.lineArray.push(s); + } + + return aLine.charAt(3) === " "; + }, + + // Split aStr at spaces, accounting for quoted values. + // Returns an array of strings. + splitReplyLine(aStr) { + // Notice: the original function did not check for escaped quotes. + return aStr + .split('"') + .flatMap((token, index) => { + const inQuotedStr = index % 2 === 1; + return inQuotedStr ? `"${token}"` : token.split(" "); + }) + .filter(s => s); + }, + + // Helper function for converting a raw controller response into a parsed object. + parseCommandResponse(reply) { + if (!reply) { + return {}; + } + const lines = reply.split("\r\n"); + const rv = {}; + for (const line of lines) { + if (this.parseReplyLine(line, rv) || rv._parseError) { + break; + } + } + return rv; + }, + + // If successful, returns a JS object with these fields: + // status.TYPE -- "NOTICE" or "WARN" + // status.PROGRESS -- integer + // status.TAG -- string + // status.SUMMARY -- string + // status.WARNING -- string (optional) + // status.REASON -- string (optional) + // status.COUNT -- integer (optional) + // status.RECOMMENDATION -- string (optional) + // status.HOSTADDR -- string (optional) + // Returns null upon failure. + parseBootstrapStatus(aStatusMsg) { + if (!aStatusMsg || !aStatusMsg.length) { + return null; + } + + let sawBootstrap = false; + const statusObj = {}; + statusObj.TYPE = "NOTICE"; + + // The following code assumes that this is a one-line response. + for (const tokenAndVal of this.splitReplyLine(aStatusMsg)) { + let token, val; + const idx = tokenAndVal.indexOf("="); + if (idx < 0) { + token = tokenAndVal; + } else { + token = tokenAndVal.substring(0, idx); + try { + val = TorParsers.unescapeString(tokenAndVal.substring(idx + 1)); + } catch (e) { + console.debug("Could not parse the token value", e); + } + if (!val) { + // skip this token/value pair. + continue; + } + } + + switch (token) { + case "BOOTSTRAP": + sawBootstrap = true; + break; + case "WARN": + case "NOTICE": + case "ERR": + statusObj.TYPE = token; + break; + case "COUNT": + case "PROGRESS": + statusObj[token] = parseInt(val, 10); + break; + default: + statusObj[token] = val; + break; + } + } + + if (!sawBootstrap) { + if (statusObj.TYPE === "NOTICE") { + console.info(aStatusMsg); + } else { + console.warn(aStatusMsg); + } + return null; + } + + return statusObj; + }, + + // Escape non-ASCII characters for use within the Tor Control protocol. + // Based on Vidalia's src/common/stringutil.cpp:string_escape(). + // Returns the new string. + escapeString(aStr) { + // Just return if all characters are printable ASCII excluding SP, ", and # + const kSafeCharRE = /^[\x21\x24-\x7E]*$/; + if (!aStr || kSafeCharRE.test(aStr)) { + return aStr; + } + const escaped = aStr + .replace("\", "\\") + .replace('"', '\"') + .replace("\n", "\n") + .replace("\r", "\r") + .replace("\t", "\t") + .replace(/[^\x20-\x7e]+/g, text => { + const encoder = new TextEncoder(); + return Array.from( + encoder.encode(text), + ch => "\x" + ch.toString(16) + ).join(""); + }); + return `"${escaped}"`; + }, + + // Unescape Tor Control string aStr (removing surrounding "" and \ escapes). + // Based on Vidalia's src/common/stringutil.cpp:string_unescape(). + // Returns the unescaped string. Throws upon failure. + // Within Torbutton, the file modules/utils.js also contains a copy of + // _strUnescape(). + unescapeString(aStr) { + if ( + !aStr || + aStr.length < 2 || + aStr[0] !== '"' || + aStr[aStr.length - 1] !== '"' + ) { + return aStr; + } + + // Regular expression by Tim Pietzcker + // https://stackoverflow.com/a/15569588 + if (!/^(?:[^"\]|\.|"(?:\.|[^"\])*")*$/.test(aStr)) { + throw new Error('Unescaped " within string'); + } + + const matchUnicode = /^(\x[0-9A-Fa-f]{2}|\[0-7]{3})+/; + let rv = ""; + let lastAdded = 1; + let bs; + while ((bs = aStr.indexOf("\", lastAdded)) !== -1) { + rv += aStr.substring(lastAdded, bs); + // We always increment lastAdded, because we will either add something, or + // ignore the backslash. + lastAdded = bs + 2; + if (lastAdded === aStr.length) { + // The string ends with ", which is illegal + throw new Error("Missing character after \"); + } + switch (aStr[bs + 1]) { + case "n": + rv += "\n"; + break; + case "r": + rv += "\r"; + break; + case "t": + rv += "\t"; + break; + case '"': + case "\": + rv += aStr[bs + 1]; + break; + default: + aStr.substring(bs).replace(matchUnicode, sequence => { + const bytes = []; + for (let i = 0; i < sequence.length; i += 4) { + if (sequence[i + 1] === "x") { + bytes.push(parseInt(sequence.substring(i + 2, i + 4), 16)); + } else { + bytes.push(parseInt(sequence.substring(i + 1, i + 4), 8)); + } + } + lastAdded = bs + sequence.length; + const decoder = new TextDecoder(); + rv += decoder.decode(new Uint8Array(bytes)); + return ""; + }); + // We have already incremented lastAdded, which means we ignore the + // backslash, and we will do something at the next one. + break; + } + } + rv += aStr.substring(lastAdded, aStr.length - 1); + return rv; + }, +}); diff --git a/toolkit/components/tor-launcher/TorProcess.jsm b/toolkit/components/tor-launcher/TorProcess.jsm new file mode 100644 index 000000000000..a8ad7b73c95e --- /dev/null +++ b/toolkit/components/tor-launcher/TorProcess.jsm @@ -0,0 +1,535 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["TorProcess"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const { Subprocess } = ChromeUtils.import( + "resource://gre/modules/Subprocess.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "TorProtocolService", + "resource://gre/modules/TorProtocolService.jsm" +); +const { TorLauncherUtil } = ChromeUtils.import( + "resource://gre/modules/TorLauncherUtil.jsm" +); + +const { TorParsers } = ChromeUtils.import( + "resource://gre/modules/TorParsers.jsm" +); + +const TorProcessStatus = Object.freeze({ + Unknown: 0, + Starting: 1, + Running: 2, + Exited: 3, +}); + +// Logger adapted from CustomizableUI.jsm +XPCOMUtils.defineLazyGetter(this, "logger", () => { + const { ConsoleAPI } = ChromeUtils.import( + "resource://gre/modules/Console.jsm" + ); + // TODO: Use a preference to set the log level. + const consoleOptions = { + maxLogLevel: "info", + prefix: "TorProcess", + }; + return new ConsoleAPI(consoleOptions); +}); + +class TorProcess { + _exeFile = null; + _dataDir = null; + _args = []; + _subprocess = null; + _status = TorProcessStatus.Unknown; + _torProcessStartTime = null; // JS Date.now() + _didConnectToTorControlPort = false; // Have we ever made a connection? + + onExit = null; + onRestart = null; + + get status() { + return this._status; + } + + get isRunning() { + return ( + this._status === TorProcessStatus.Starting || + this._status === TorProcessStatus.Running + ); + } + + async start() { + if (this._subprocess) { + return; + } + + await this._fixupTorrc(); + + this._status = TorProcessStatus.Unknown; + + try { + this._makeArgs(); + this._addControlPortArg(); + this._addSocksPortArg(); + + const pid = Services.appinfo.processID; + if (pid !== 0) { + this._args.push("__OwningControllerProcess"); + this._args.push("" + pid); + } + + if (TorLauncherUtil.shouldShowNetworkSettings) { + this._args.push("DisableNetwork"); + this._args.push("1"); + } + + // Set an environment variable that points to the Tor data directory. + // This is used by meek-client-torbrowser to find the location for + // the meek browser profile. + const environment = { + TOR_BROWSER_TOR_DATA_DIR: this._dataDir.path, + }; + + // On Windows, prepend the Tor program directory to PATH. This is needed + // so that pluggable transports can find OpenSSL DLLs, etc. + // See https://trac.torproject.org/projects/tor/ticket/10845 + if (TorLauncherUtil.isWindows) { + let path = this._exeFile.parent.path; + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (env.exists("PATH")) { + path += ";" + env.get("PATH"); + } + environment.PATH = path; + } + + this._status = TorProcessStatus.Starting; + this._didConnectToTorControlPort = false; + + // useful for simulating slow tor daemon launch + const kPrefTorDaemonLaunchDelay = "extensions.torlauncher.launch_delay"; + const launchDelay = Services.prefs.getIntPref( + kPrefTorDaemonLaunchDelay, + 0 + ); + if (launchDelay > 0) { + await new Promise(resolve => setTimeout(() => resolve(), launchDelay)); + } + + logger.debug(`Starting ${this._exeFile.path}`, this._args); + const options = { + command: this._exeFile.path, + arguments: this._args, + environment, + environmentAppend: true, + stderr: "pipe", + }; + this._subprocess = await Subprocess.call(options); + this._watchProcess(); + this._status = TorProcessStatus.Running; + this._torProcessStartTime = Date.now(); + } catch (e) { + this._status = TorProcessStatus.Exited; + this._subprocess = null; + logger.error("startTor error:", e); + throw e; + } + } + + // Forget about a process. + // + // Instead of killing the tor process, we rely on the TAKEOWNERSHIP feature + // to shut down tor when we close the control port connection. + // + // Previously, we sent a SIGNAL HALT command to the tor control port, + // but that caused hangs upon exit in the Firefox 24.x based browser. + // Apparently, Firefox does not like to process socket I/O while + // quitting if the browser did not finish starting up (e.g., when + // someone presses the Quit button on our Network Settings window + // during startup). + // + // Still, before closing the owning connection, this class should forget about + // the process, so that future notifications will be ignored. + forget() { + this._subprocess = null; + this._status = TorProcessStatus.Exited; + } + + // The owner of the process can use this function to tell us that they + // successfully connected to the control port. This information will be used + // only to decide which text to show in the confirmation dialog if tor exits. + connectionWorked() { + this._didConnectToTorControlPort = true; + } + + async _watchProcess() { + const watched = this._subprocess; + if (!watched) { + return; + } + try { + const { exitCode } = await watched.wait(); + + if (watched !== this._subprocess) { + logger.debug(`A Tor process exited with code ${exitCode}.`); + } else if (exitCode) { + logger.warn(`The watched Tor process exited with code ${exitCode}.`); + } else { + logger.info("The Tor process exited."); + } + } catch (e) { + logger.error("Failed to watch the tor process", e); + } + + if (watched === this._subprocess) { + this._processExitedUnexpectedly(); + } + } + + _processExitedUnexpectedly() { + this._subprocess = null; + this._status = TorProcessStatus.Exited; + + // TODO: Move this logic somewhere else? + let s; + if (!this._didConnectToTorControlPort) { + // tor might be misconfigured, becauser we could never connect to it + const key = "tor_exited_during_startup"; + s = TorLauncherUtil.getLocalizedString(key); + } else { + // tor exited suddenly, so configuration should be okay + s = + TorLauncherUtil.getLocalizedString("tor_exited") + + "\n\n" + + TorLauncherUtil.getLocalizedString("tor_exited2"); + } + logger.info(s); + const defaultBtnLabel = TorLauncherUtil.getLocalizedString("restart_tor"); + let cancelBtnLabel = "OK"; + try { + const kSysBundleURI = "chrome://global/locale/commonDialogs.properties"; + const sysBundle = Services.strings.createBundle(kSysBundleURI); + cancelBtnLabel = sysBundle.GetStringFromName(cancelBtnLabel); + } catch (e) { + logger.warn("Could not localize the cancel button", e); + } + + const restart = TorLauncherUtil.showConfirm( + null, + s, + defaultBtnLabel, + cancelBtnLabel + ); + if (restart) { + this.start().then(() => { + if (this.onRestart) { + this.onRestart(); + } + }); + } else if (this.onExit) { + this.onExit(); + } + } + + _makeArgs() { + // Ideally, we would cd to the Firefox application directory before + // starting tor (but we don't know how to do that). Instead, we + // rely on the TBB launcher to start Firefox from the right place. + + // Get the Tor data directory first so it is created before we try to + // construct paths to files that will be inside it. + this._exeFile = TorLauncherUtil.getTorFile("tor", false); + const torrcFile = TorLauncherUtil.getTorFile("torrc", true); + this._dataDir = TorLauncherUtil.getTorFile("tordatadir", true); + const onionAuthDir = TorLauncherUtil.getTorFile("toronionauthdir", true); + const hashedPassword = TorProtocolService.torGetPassword(true); + let detailsKey; + if (!this._exeFile) { + detailsKey = "tor_missing"; + } else if (!torrcFile) { + detailsKey = "torrc_missing"; + } else if (!this._dataDir) { + detailsKey = "datadir_missing"; + } else if (!onionAuthDir) { + detailsKey = "onionauthdir_missing"; + } else if (!hashedPassword) { + detailsKey = "password_hash_missing"; + } + if (detailsKey) { + const details = TorLauncherUtil.getLocalizedString(detailsKey); + const key = "unable_to_start_tor"; + const err = TorLauncherUtil.getFormattedLocalizedString( + key, + [details], + 1 + ); + throw new Error(err); + } + + const torrcDefaultsFile = TorLauncherUtil.getTorFile( + "torrc-defaults", + false + ); + // The geoip and geoip6 files are in the same directory as torrc-defaults. + const geoipFile = torrcDefaultsFile.clone(); + geoipFile.leafName = "geoip"; + const geoip6File = torrcDefaultsFile.clone(); + geoip6File.leafName = "geoip6"; + + this._args = []; + if (torrcDefaultsFile) { + this._args.push("--defaults-torrc"); + this._args.push(torrcDefaultsFile.path); + } + this._args.push("-f"); + this._args.push(torrcFile.path); + this._args.push("DataDirectory"); + this._args.push(this._dataDir.path); + this._args.push("ClientOnionAuthDir"); + this._args.push(onionAuthDir.path); + this._args.push("GeoIPFile"); + this._args.push(geoipFile.path); + this._args.push("GeoIPv6File"); + this._args.push(geoip6File.path); + this._args.push("HashedControlPassword"); + this._args.push(hashedPassword); + } + + _addControlPortArg() { + // Include a ControlPort argument to support switching between + // a TCP port and an IPC port (e.g., a Unix domain socket). We + // include a "+__" prefix so that (1) this control port is added + // to any control ports that the user has defined in their torrc + // file and (2) it is never written to torrc. + let controlPortArg; + const controlIPCFile = TorProtocolService.torGetControlIPCFile(); + const controlPort = TorProtocolService.torGetControlPort(); + if (controlIPCFile) { + controlPortArg = this._ipcPortArg(controlIPCFile); + } else if (controlPort) { + controlPortArg = "" + controlPort; + } + if (controlPortArg) { + this._args.push("+__ControlPort"); + this._args.push(controlPortArg); + } + } + + _addSocksPortArg() { + // Include a SocksPort argument to support switching between + // a TCP port and an IPC port (e.g., a Unix domain socket). We + // include a "+__" prefix so that (1) this SOCKS port is added + // to any SOCKS ports that the user has defined in their torrc + // file and (2) it is never written to torrc. + const socksPortInfo = TorProtocolService.torGetSOCKSPortInfo(); + if (socksPortInfo) { + let socksPortArg; + if (socksPortInfo.ipcFile) { + socksPortArg = this._ipcPortArg(socksPortInfo.ipcFile); + } else if (socksPortInfo.host && socksPortInfo.port != 0) { + socksPortArg = socksPortInfo.host + ":" + socksPortInfo.port; + } + if (socksPortArg) { + let socksPortFlags = Services.prefs.getCharPref( + "extensions.torlauncher.socks_port_flags", + "IPv6Traffic PreferIPv6 KeepAliveIsolateSOCKSAuth" + ); + if (socksPortFlags) { + socksPortArg += " " + socksPortFlags; + } + this._args.push("+__SocksPort"); + this._args.push(socksPortArg); + } + } + } + + // Return a ControlPort or SocksPort argument for aIPCFile (an nsIFile). + // The result is unix:/path or unix:"/path with spaces" with appropriate + // C-style escaping within the path portion. + _ipcPortArg(aIPCFile) { + return "unix:" + TorParsers.escapeString(aIPCFile.path); + } + + async _fixupTorrc() { + // If we have not already done so, remove any ControlPort and SocksPort + // lines from the user's torrc file that may conflict with the arguments + // we plan to pass when starting tor. + // See bugs 20761 and 22283. + const kTorrcFixupVersion = 2; + const kTorrcFixupPref = "extensions.torlauncher.torrc_fixup_version"; + if (Services.prefs.getIntPref(kTorrcFixupPref, 0) > kTorrcFixupVersion) { + return true; + } + + let torrcFile = TorLauncherUtil.getTorFile("torrc", true); + if (!torrcFile) { + // No torrc file; nothing to fixup. + return true; + } + torrcFile = torrcFile.path; + + let torrcStr; + try { + torrcStr = await IOUtils.readUTF8(torrcFile); + } catch (e) { + logger.error(`Could not read ${torrcFile}:`, e); + return false; + } + if (!torrcStr.length) { + return true; + } + + const controlIPCFile = TorProtocolService.torGetControlIPCFile(); + const controlPort = TorProtocolService.torGetControlPort(); + const socksPortInfo = TorProtocolService.torGetSOCKSPortInfo(); + + const valueIsUnixDomainSocket = aValue => { + // Handle several cases: + // "unix:/path options" + // unix:"/path" options + // unix:/path options + if (aValue.startsWith('"')) { + aValue = TorParsers.unescapeString(aValue); + } + return aValue.startsWith("unix:"); + }; + const valueContainsPort = (aValue, aPort) => { + // Check for a match, ignoring "127.0.0.1" and "localhost" prefixes. + let val = TorParsers.unescapeString(aValue); + const pieces = val.split(":"); + if ( + pieces.length >= 2 && + (pieces[0] === "127.0.0.1" || pieces[0].toLowerCase() === "localhost") + ) { + val = pieces[1]; + } + return aPort === parseInt(val); + }; + + let removedLinesCount = 0; + const revisedLines = []; + const lines = this._joinContinuedTorrcLines(torrcStr); + lines.forEach(aLine => { + let removeLine = false; + // Look for "+ControlPort value" or "ControlPort value", skipping leading + // whitespace and ignoring case. + let matchResult = aLine.match(/\s*+*controlport\s+(.*)/i); + if (matchResult) { + removeLine = valueIsUnixDomainSocket(matchResult[1]); + if (!removeLine && !controlIPCFile) { + removeLine = valueContainsPort(matchResult[1], controlPort); + } + } else if (socksPortInfo) { + // Look for "+SocksPort value" or "SocksPort value", skipping leading + // whitespace and ignoring case. + matchResult = aLine.match(/\s*+*socksport\s+(.*)/i); + if (matchResult) { + removeLine = valueIsUnixDomainSocket(matchResult[1]); + if (!removeLine && !socksPortInfo.ipcFile) { + removeLine = valueContainsPort(matchResult[1], socksPortInfo.port); + } + } + } + + if (removeLine) { + ++removedLinesCount; + logger.info(`fixupTorrc: removing ${aLine}`); + } else { + revisedLines.push(aLine); + } + }); + + if (removedLinesCount > 0) { + const data = new TextEncoder().encode(revisedLines.join("\n")); + try { + await IOUtils.write(torrcFile, data, { + tmpPath: torrcFile + ".tmp", + }); + } catch (e) { + logger.error(`Failed to overwrite file ${torrcFile}:`, e); + return false; + } + logger.info( + `fixupTorrc: removed ${removedLinesCount} configuration options` + ); + } + + Services.prefs.setIntPref(kTorrcFixupPref, kTorrcFixupVersion); + return true; + } + + // Split aTorrcStr into lines, joining continued lines. + _joinContinuedTorrcLines(aTorrcStr) { + const lines = []; + const rawLines = aTorrcStr.split("\n"); + let isContinuedLine = false; + let tmpLine; + rawLines.forEach(aLine => { + let len = aLine.length; + + // Strip trailing CR if present. + if (len > 0 && aLine.substr(len - 1) === "\r") { + --len; + aLine = aLine.substr(0, len); + } + + // Check for a continued line. This is indicated by a trailing \ or, if + // we are already within a continued line sequence, a trailing comment. + if (len > 0 && aLine.substr(len - 1) === "\") { + --len; + aLine = aLine.substr(0, len); + + // If this is the start of a continued line and it only contains a + // keyword (i.e., no spaces are present), append a space so that + // the keyword will be recognized (as it is by tor) after we join + // the pieces of the continued line into one line. + if (!isContinuedLine && !aLine.includes(" ")) { + aLine += " "; + } + + isContinuedLine = true; + } else if (isContinuedLine) { + if (!len) { + isContinuedLine = false; + } else { + // Check for a comment. According to tor's doc/torrc_format.txt, + // comments do not terminate a sequence of continued lines. + let idx = aLine.indexOf("#"); + if (idx < 0) { + isContinuedLine = false; // Not a comment; end continued line. + } else { + // Remove trailing comment from continued line. The continued + // line sequence continues. + aLine = aLine.substr(0, idx); + } + } + } + + if (isContinuedLine) { + if (tmpLine) { + tmpLine += aLine; + } else { + tmpLine = aLine; + } + } else if (tmpLine) { + lines.push(tmpLine + aLine); + tmpLine = undefined; + } else { + lines.push(aLine); + } + }); + + return lines; + } +} diff --git a/toolkit/components/tor-launcher/TorProtocolService.jsm b/toolkit/components/tor-launcher/TorProtocolService.jsm new file mode 100644 index 000000000000..de9c54c71137 --- /dev/null +++ b/toolkit/components/tor-launcher/TorProtocolService.jsm @@ -0,0 +1,752 @@ +// Copyright (c) 2021, The Tor Project, Inc. + +"use strict"; + +var EXPORTED_SYMBOLS = ["TorProtocolService"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm"); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +Cu.importGlobalProperties(["crypto"]); + +const { TorParsers } = ChromeUtils.import( + "resource://gre/modules/TorParsers.jsm" +); +const { TorLauncherUtil } = ChromeUtils.import( + "resource://gre/modules/TorLauncherUtil.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "TorMonitorService", + "resource://gre/modules/TorMonitorService.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "configureControlPortModule", + "resource://torbutton/modules/tor-control-port.js" +); +ChromeUtils.defineModuleGetter( + this, + "controller", + "resource://torbutton/modules/tor-control-port.js" +); + +const TorTopics = Object.freeze({ + ProcessExited: "TorProcessExited", + ProcessRestarted: "TorProcessRestarted", +}); + +// Logger adapted from CustomizableUI.jsm +XPCOMUtils.defineLazyGetter(this, "logger", () => { + const { ConsoleAPI } = ChromeUtils.import( + "resource://gre/modules/Console.jsm" + ); + // TODO: Use a preference to set the log level. + const consoleOptions = { + // maxLogLevel: "warn", + maxLogLevel: "all", + prefix: "TorProtocolService", + }; + return new ConsoleAPI(consoleOptions); +}); + +// Manage the connection to tor's control port, to update its settings and query +// other useful information. +// +// NOTE: Many Tor protocol functions return a reply object, which is a +// a JavaScript object that has the following fields: +// reply.statusCode -- integer, e.g., 250 +// reply.lineArray -- an array of strings returned by tor +// For GetConf calls, the aKey prefix is removed from the lineArray strings. +const TorProtocolService = { + _inited: false, + + // Maintain a map of tor settings set by Tor Browser so that we don't + // repeatedly set the same key/values over and over. + // This map contains string keys to primitives or array values. + _settingsCache: new Map(), + + _controlPort: null, + _controlHost: null, + _controlIPCFile: null, // An nsIFile if using IPC for control port. + _controlPassword: null, // JS string that contains hex-encoded password. + _SOCKSPortInfo: null, // An object that contains ipcFile, host, port. + + _controlConnection: null, // This is cached and reused. + _connectionQueue: [], + + // Public methods + + async init() { + if (this._inited) { + return; + } + this._inited = true; + + Services.obs.addObserver(this, TorTopics.ProcessExited); + Services.obs.addObserver(this, TorTopics.ProcessRestarted); + + await this._setSockets(); + + logger.debug("TorProtocolService initialized"); + }, + + uninit() { + Services.obs.removeObserver(this, TorTopics.ProcessExited); + Services.obs.removeObserver(this, TorTopics.ProcessRestarted); + this._closeConnection(); + }, + + observe(subject, topic, data) { + if (topic === TorTopics.ProcessExited) { + this._closeConnection(); + } else if (topic === TorTopics.ProcessRestarted) { + this._reconnect(); + } + }, + + // takes a Map containing tor settings + // throws on error + async writeSettings(aSettingsObj) { + // only write settings that have changed + const newSettings = Array.from(aSettingsObj).filter(([setting, value]) => { + // make sure we have valid data here + this._assertValidSetting(setting, value); + + if (!this._settingsCache.has(setting)) { + // no cached setting, so write + return true; + } + + const cachedValue = this._settingsCache.get(setting); + if (value === cachedValue) { + return false; + } else if (Array.isArray(value) && Array.isArray(cachedValue)) { + // compare arrays member-wise + if (value.length !== cachedValue.length) { + return true; + } + for (let i = 0; i < value.length; i++) { + if (value[i] !== cachedValue[i]) { + return true; + } + } + return false; + } + // some other different values + return true; + }); + + // only write if new setting to save + if (newSettings.length) { + const settingsObject = Object.fromEntries(newSettings); + await this.setConfWithReply(settingsObject); + + // save settings to cache after successfully writing to Tor + for (const [setting, value] of newSettings) { + this._settingsCache.set(setting, value); + } + } + }, + + async readStringArraySetting(aSetting) { + const value = await this._readSetting(aSetting); + this._settingsCache.set(aSetting, value); + return value; + }, + + // writes current tor settings to disk + async flushSettings() { + await this.sendCommand("SAVECONF"); + }, + + async connect() { + const kTorConfKeyDisableNetwork = "DisableNetwork"; + const settings = {}; + settings[kTorConfKeyDisableNetwork] = false; + await this.setConfWithReply(settings); + await this.sendCommand("SAVECONF"); + TorMonitorService.clearBootstrapError(); + TorMonitorService.retrieveBootstrapStatus(); + }, + + async stopBootstrap() { + // Tell tor to disable use of the network; this should stop the bootstrap + // process. + try { + const settings = { DisableNetwork: true }; + await this.setConfWithReply(settings); + } catch (e) { + logger.error("Error stopping bootstrap", e); + } + // We are not interested in waiting for this, nor in **catching its error**, + // so we do not await this. We just want to be notified when the bootstrap + // status is actually updated through observers. + TorMonitorService.retrieveBootstrapStatus(); + }, + + // TODO: transform the following 4 functions in getters. At the moment they + // are also used in torbutton. + + // Returns Tor password string or null if an error occurs. + torGetPassword(aPleaseHash) { + const pw = this._controlPassword; + return aPleaseHash ? this._hashPassword(pw) : pw; + }, + + torGetControlIPCFile() { + return this._controlIPCFile?.clone(); + }, + + torGetControlPort() { + return this._controlPort; + }, + + torGetSOCKSPortInfo() { + return this._SOCKSPortInfo; + }, + + // Public, but called only internally + + // Executes a command on the control port. + // Return a reply object or null if a fatal error occurs. + async sendCommand(cmd, args) { + let conn, reply; + const maxAttempts = 2; + for (let attempt = 0; !reply && attempt < maxAttempts; attempt++) { + try { + conn = await this._getConnection(); + try { + if (conn) { + reply = await conn.sendCommand(cmd + (args ? " " + args : "")); + if (reply) { + // Return for reuse. + this._returnConnection(); + } else { + // Connection is bad. + logger.warn( + "sendCommand returned an empty response, taking the connection as broken and closing it." + ); + this._closeConnection(); + } + } + } catch (e) { + logger.error(`Cannot send the command ${cmd}`, e); + this._closeConnection(); + } + } catch (e) { + logger.error("Cannot get a connection to the control port", e); + } + } + + // We failed to acquire the controller after multiple attempts. + // Try again after some time. + if (!conn) { + logger.info( + "sendCommand: Acquiring control connection failed", + cmd, + args + ); + return new Promise(resolve => + setTimeout(() => { + resolve(this.sendCommand(cmd, args)); + }, 250) + ); + } + + if (!reply) { + throw new Error(`${cmd} sent an empty response`); + } + + // TODO: Move the parsing of the reply to the controller, because anyone + // calling sendCommand on it actually wants a parsed reply. + + reply = TorParsers.parseCommandResponse(reply); + if (!TorParsers.commandSucceeded(reply)) { + if (reply?.lineArray) { + throw new Error(reply.lineArray.join("\n")); + } + throw new Error(`${cmd} failed with code ${reply.statusCode}`); + } + + return reply; + }, + + // Perform a SETCONF command. + // aSettingsObj should be a JavaScript object with keys (property values) + // that correspond to tor config. keys. The value associated with each + // key should be a simple string, a string array, or a Boolean value. + // If an associated value is undefined or null, a key with no value is + // passed in the SETCONF command. + // Throws in case of error, or returns a reply object. + async setConfWithReply(settings) { + if (!settings) { + throw new Error("Empty settings object"); + } + const args = Object.entries(settings) + .map(([key, val]) => { + if (val === undefined || val === null) { + return key; + } + const valType = typeof val; + let rv = `${key}=`; + if (valType === "boolean") { + rv += val ? "1" : "0"; + } else if (Array.isArray(val)) { + rv += val.map(TorParsers.escapeString).join(` ${key}=`); + } else if (valType === "string") { + rv += TorParsers.escapeString(val); + } else { + logger.error(`Got unsupported type for ${key}`, val); + throw new Error(`Unsupported type ${valType} (key ${key})`); + } + return rv; + }) + .filter(arg => arg); + if (!args.length) { + throw new Error("No settings to set"); + } + + await this.sendCommand("SETCONF", args.join(" ")); + }, + + // Public, never called? + + async readBoolSetting(aSetting) { + let value = await this._readBoolSetting(aSetting); + this._settingsCache.set(aSetting, value); + return value; + }, + + async readStringSetting(aSetting) { + let value = await this._readStringSetting(aSetting); + this._settingsCache.set(aSetting, value); + return value; + }, + + // Private + + async _setSockets() { + try { + const isWindows = TorLauncherUtil.isWindows; + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + // Determine how Tor Launcher will connect to the Tor control port. + // Environment variables get top priority followed by preferences. + if (!isWindows && env.exists("TOR_CONTROL_IPC_PATH")) { + const ipcPath = env.get("TOR_CONTROL_IPC_PATH"); + this._controlIPCFile = new FileUtils.File(ipcPath); + } else { + // Check for TCP host and port environment variables. + if (env.exists("TOR_CONTROL_HOST")) { + this._controlHost = env.get("TOR_CONTROL_HOST"); + } + if (env.exists("TOR_CONTROL_PORT")) { + this._controlPort = parseInt(env.get("TOR_CONTROL_PORT"), 10); + } + + const useIPC = + !isWindows && + Services.prefs.getBoolPref( + "extensions.torlauncher.control_port_use_ipc", + false + ); + if (!this._controlHost && !this._controlPort && useIPC) { + this._controlIPCFile = TorLauncherUtil.getTorFile( + "control_ipc", + false + ); + } else { + if (!this._controlHost) { + this._controlHost = Services.prefs.getCharPref( + "extensions.torlauncher.control_host", + "127.0.0.1" + ); + } + if (!this._controlPort) { + this._controlPort = Services.prefs.getIntPref( + "extensions.torlauncher.control_port", + 9151 + ); + } + } + } + + // Populate _controlPassword so it is available when starting tor. + if (env.exists("TOR_CONTROL_PASSWD")) { + this._controlPassword = env.get("TOR_CONTROL_PASSWD"); + } else if (env.exists("TOR_CONTROL_COOKIE_AUTH_FILE")) { + // TODO: test this code path (TOR_CONTROL_COOKIE_AUTH_FILE). + const cookiePath = env.get("TOR_CONTROL_COOKIE_AUTH_FILE"); + if (cookiePath) { + this._controlPassword = await this._readAuthenticationCookie( + cookiePath + ); + } + } + if (!this._controlPassword) { + this._controlPassword = this._generateRandomPassword(); + } + + // Determine what kind of SOCKS port Tor and the browser will use. + // On Windows (where Unix domain sockets are not supported), TCP is + // always used. + // + // The following environment variables are supported and take + // precedence over preferences: + // TOR_SOCKS_IPC_PATH (file system path; ignored on Windows) + // TOR_SOCKS_HOST + // TOR_SOCKS_PORT + // + // The following preferences are consulted: + // network.proxy.socks + // network.proxy.socks_port + // extensions.torlauncher.socks_port_use_ipc (Boolean) + // extensions.torlauncher.socks_ipc_path (file system path) + // If extensions.torlauncher.socks_ipc_path is empty, a default + // path is used (<tor-data-directory>/socks.socket). + // + // When using TCP, if a value is not defined via an env variable it is + // taken from the corresponding browser preference if possible. The + // exceptions are: + // If network.proxy.socks contains a file: URL, a default value of + // "127.0.0.1" is used instead. + // If the network.proxy.socks_port value is 0, a default value of + // 9150 is used instead. + // + // Supported scenarios: + // 1. By default, an IPC object at a default path is used. + // 2. If extensions.torlauncher.socks_port_use_ipc is set to false, + // a TCP socket at 127.0.0.1:9150 is used, unless different values + // are set in network.proxy.socks and network.proxy.socks_port. + // 3. If the TOR_SOCKS_IPC_PATH env var is set, an IPC object at that + // path is used (e.g., a Unix domain socket). + // 4. If the TOR_SOCKS_HOST and/or TOR_SOCKS_PORT env vars are set, TCP + // is used. Values not set via env vars will be taken from the + // network.proxy.socks and network.proxy.socks_port prefs as described + // above. + // 5. If extensions.torlauncher.socks_port_use_ipc is true and + // extensions.torlauncher.socks_ipc_path is set, an IPC object at + // the specified path is used. + // 6. Tor Launcher is disabled. Torbutton will respect the env vars if + // present; if not, the values in network.proxy.socks and + // network.proxy.socks_port are used without modification. + + let useIPC; + this._SOCKSPortInfo = { ipcFile: undefined, host: undefined, port: 0 }; + if (!isWindows && env.exists("TOR_SOCKS_IPC_PATH")) { + let ipcPath = env.get("TOR_SOCKS_IPC_PATH"); + this._SOCKSPortInfo.ipcFile = new FileUtils.File(ipcPath); + useIPC = true; + } else { + // Check for TCP host and port environment variables. + if (env.exists("TOR_SOCKS_HOST")) { + this._SOCKSPortInfo.host = env.get("TOR_SOCKS_HOST"); + useIPC = false; + } + if (env.exists("TOR_SOCKS_PORT")) { + this._SOCKSPortInfo.port = parseInt(env.get("TOR_SOCKS_PORT"), 10); + useIPC = false; + } + } + + if (useIPC === undefined) { + useIPC = + !isWindows && + Services.prefs.getBoolPref( + "extensions.torlauncher.socks_port_use_ipc", + false + ); + } + + // Fill in missing SOCKS info from prefs. + if (useIPC) { + if (!this._SOCKSPortInfo.ipcFile) { + this._SOCKSPortInfo.ipcFile = TorLauncherUtil.getTorFile( + "socks_ipc", + false + ); + } + } else { + if (!this._SOCKSPortInfo.host) { + let socksAddr = Services.prefs.getCharPref( + "network.proxy.socks", + "127.0.0.1" + ); + let socksAddrHasHost = socksAddr && !socksAddr.startsWith("file:"); + this._SOCKSPortInfo.host = socksAddrHasHost ? socksAddr : "127.0.0.1"; + } + + if (!this._SOCKSPortInfo.port) { + let socksPort = Services.prefs.getIntPref( + "network.proxy.socks_port", + 0 + ); + // This pref is set as 0 by default in Firefox, use 9150 if we get 0. + this._SOCKSPortInfo.port = socksPort != 0 ? socksPort : 9150; + } + } + + logger.info("SOCKS port type: " + (useIPC ? "IPC" : "TCP")); + if (useIPC) { + logger.info(`ipcFile: ${this._SOCKSPortInfo.ipcFile.path}`); + } else { + logger.info(`SOCKS host: ${this._SOCKSPortInfo.host}`); + logger.info(`SOCKS port: ${this._SOCKSPortInfo.port}`); + } + + // Set the global control port info parameters. + // These values may be overwritten by torbutton when it initializes, but + // torbutton's values *should* be identical. + configureControlPortModule( + this._controlIPCFile, + this._controlHost, + this._controlPort, + this._controlPassword + ); + } catch (e) { + logger.error("Failed to get environment variables", e); + } + }, + + _assertValidSettingKey(aSetting) { + // ensure the 'key' is a string + if (typeof aSetting !== "string") { + throw new Error( + `Expected setting of type string but received ${typeof aSetting}` + ); + } + }, + + _assertValidSetting(aSetting, aValue) { + this._assertValidSettingKey(aSetting); + switch (typeof aValue) { + case "boolean": + case "string": + return; + case "object": + if (aValue === null) { + return; + } else if (Array.isArray(aValue)) { + for (const element of aValue) { + if (typeof element !== "string") { + throw new Error( + `Setting '${aSetting}' array contains value of invalid type '${typeof element}'` + ); + } + } + return; + } + // fall through + default: + throw new Error( + `Invalid object type received for setting '${aSetting}'` + ); + } + }, + + // Perform a GETCONF command. + async _readSetting(aSetting) { + this._assertValidSettingKey(aSetting); + + const cmd = "GETCONF"; + let reply = await this.sendCommand(cmd, aSetting); + reply = TorParsers.parseReply(cmd, aSetting, reply); + if (TorParsers.commandSucceeded(reply)) { + return reply.lineArray; + } + throw new Error(reply.lineArray.join("\n")); + }, + + async _readStringSetting(aSetting) { + let lineArray = await this._readSetting(aSetting); + if (lineArray.length !== 1) { + throw new Error( + `Expected an array with length 1 but received array of length ${lineArray.length}` + ); + } + return lineArray[0]; + }, + + async _readBoolSetting(aSetting) { + const value = this._readStringSetting(aSetting); + switch (value) { + case "0": + return false; + case "1": + return true; + default: + throw new Error(`Expected boolean (1 or 0) but received '${value}'`); + } + }, + + // Opens an authenticated connection, sets it to this._controlConnection, and + // return it. + async _getConnection() { + if (!this._controlConnection) { + const avoidCache = true; + this._controlConnection = await controller(avoidCache); + } + if (this._controlConnection.inUse) { + await new Promise((resolve, reject) => + this._connectionQueue.push({ resolve, reject }) + ); + } else { + this._controlConnection.inUse = true; + } + return this._controlConnection; + }, + + _returnConnection() { + if (this._connectionQueue.length) { + this._connectionQueue.shift().resolve(); + } else { + this._controlConnection.inUse = false; + } + }, + + // If aConn is omitted, the cached connection is closed. + _closeConnection() { + if (this._controlConnection) { + logger.info("Closing the control connection"); + this._controlConnection.close(); + this._controlConnection = null; + } + for (const promise of this._connectionQueue) { + promise.reject("Connection closed"); + } + this._connectionQueue = []; + }, + + async _reconnect() { + this._closeConnection(); + const conn = await this._getConnection(); + logger.debug("Reconnected to the control port."); + this._returnConnection(conn); + }, + + async _readAuthenticationCookie(aPath) { + const bytes = await IOUtils.read(aPath); + return Array.from(bytes, b => this._toHex(b, 2)).join(""); + }, + + // Returns a random 16 character password, hex-encoded. + _generateRandomPassword() { + // Similar to Vidalia's crypto_rand_string(). + const kPasswordLen = 16; + const kMinCharCode = "!".charCodeAt(0); + const kMaxCharCode = "~".charCodeAt(0); + let pwd = ""; + for (let i = 0; i < kPasswordLen; ++i) { + const val = this._cryptoRandInt(kMaxCharCode - kMinCharCode + 1); + if (val < 0) { + logger.error("_cryptoRandInt() failed"); + return null; + } + pwd += this._toHex(kMinCharCode + val, 2); + } + + return pwd; + }, + + // Based on Vidalia's TorSettings::hashPassword(). + _hashPassword(aHexPassword) { + if (!aHexPassword) { + return null; + } + + // Generate a random, 8 byte salt value. + const salt = Array.from(crypto.getRandomValues(new Uint8Array(8))); + + // Convert hex-encoded password to an array of bytes. + const password = []; + for (let i = 0; i < aHexPassword.length; i += 2) { + password.push(parseInt(aHexPassword.substring(i, i + 2), 16)); + } + + // Run through the S2K algorithm and convert to a string. + const kCodedCount = 96; + const hashVal = this._cryptoSecretToKey(password, salt, kCodedCount); + if (!hashVal) { + logger.error("_cryptoSecretToKey() failed"); + return null; + } + + const arrayToHex = aArray => + aArray.map(item => this._toHex(item, 2)).join(""); + let rv = "16:"; + rv += arrayToHex(salt); + rv += this._toHex(kCodedCount, 2); + rv += arrayToHex(hashVal); + return rv; + }, + + // Returns -1 upon failure. + _cryptoRandInt(aMax) { + // Based on tor's crypto_rand_int(). + const maxUInt = 0xffffffff; + if (aMax <= 0 || aMax > maxUInt) { + return -1; + } + + const cutoff = maxUInt - (maxUInt % aMax); + let val = cutoff; + while (val >= cutoff) { + const uint32 = new Uint32Array(1); + crypto.getRandomValues(uint32); + val = uint32[0]; + } + return val % aMax; + }, + + // _cryptoSecretToKey() is similar to Vidalia's crypto_secret_to_key(). + // It generates and returns a hash of aPassword by following the iterated + // and salted S2K algorithm (see RFC 2440 section 3.6.1.3). + // Returns an array of bytes. + _cryptoSecretToKey(aPassword, aSalt, aCodedCount) { + if (!aPassword || !aSalt) { + return null; + } + + const inputArray = aSalt.concat(aPassword); + + // Subtle crypto only has the final digest, and does not allow incremental + // updates. Also, it is async, so we should hash and keep the hash in a + // variable if we wanted to switch to getters. + // So, keeping this implementation should be okay for now. + const hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA1); + const kEXPBIAS = 6; + let count = (16 + (aCodedCount & 15)) << ((aCodedCount >> 4) + kEXPBIAS); + while (count > 0) { + if (count > inputArray.length) { + hasher.update(inputArray, inputArray.length); + count -= inputArray.length; + } else { + const finalArray = inputArray.slice(0, count); + hasher.update(finalArray, finalArray.length); + count = 0; + } + } + return hasher + .finish(false) + .split("") + .map(b => b.charCodeAt(0)); + }, + + _toHex(aValue, aMinLen) { + return aValue.toString(16).padStart(aMinLen, "0"); + }, +}; diff --git a/toolkit/components/tor-launcher/TorStartupService.jsm b/toolkit/components/tor-launcher/TorStartupService.jsm new file mode 100644 index 000000000000..31a82d4c9510 --- /dev/null +++ b/toolkit/components/tor-launcher/TorStartupService.jsm @@ -0,0 +1,70 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["TorStartupService"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +// We will use the modules only when the profile is loaded, so prefer lazy +// loading +ChromeUtils.defineModuleGetter( + this, + "TorLauncherUtil", + "resource://gre/modules/TorLauncherUtil.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "TorMonitorService", + "resource://gre/modules/TorMonitorService.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "TorProtocolService", + "resource://gre/modules/TorProtocolService.jsm" +); + +/* Browser observer topis */ +const BrowserTopics = Object.freeze({ + ProfileAfterChange: "profile-after-change", + QuitApplicationGranted: "quit-application-granted", +}); + +let gInited = false; + +// This class is registered as an observer, and will be instanced automatically +// by Firefox. +// When it observes profile-after-change, it initializes whatever is needed to +// launch Tor. +class TorStartupService { + _defaultPreferencesAreLoaded = false; + + observe(aSubject, aTopic, aData) { + if (aTopic === BrowserTopics.ProfileAfterChange && !gInited) { + this._init(); + } else if (aTopic === BrowserTopics.QuitApplicationGranted) { + this._uninit(); + } + } + + async _init() { + Services.obs.addObserver(this, BrowserTopics.QuitApplicationGranted); + + // Starts TorProtocolService first, because it configures the controller + // factory, too. + await TorProtocolService.init(); + TorMonitorService.init(); + + gInited = true; + } + + _uninit() { + Services.obs.removeObserver(this, BrowserTopics.QuitApplicationGranted); + + // Close any helper connection first... + TorProtocolService.uninit(); + // ... and only then closes the event monitor connection, which will cause + // Tor to stop. + TorMonitorService.uninit(); + + TorLauncherUtil.cleanupTempDirectories(); + } +} diff --git a/toolkit/components/tor-launcher/components.conf b/toolkit/components/tor-launcher/components.conf new file mode 100644 index 000000000000..4e62a7a1e24f --- /dev/null +++ b/toolkit/components/tor-launcher/components.conf @@ -0,0 +1,10 @@ +Classes = [ + { + "cid": "{df46c65d-be2b-4d16-b280-69733329eecf}", + "contract_ids": [ + "@torproject.org/tor-startup-service;1" + ], + "jsm": "resource://gre/modules/TorStartupService.jsm", + "constructor": "TorStartupService", + }, +] diff --git a/toolkit/components/tor-launcher/moz.build b/toolkit/components/tor-launcher/moz.build new file mode 100644 index 000000000000..2b3d00077168 --- /dev/null +++ b/toolkit/components/tor-launcher/moz.build @@ -0,0 +1,17 @@ +EXTRA_JS_MODULES += [ + "TorBootstrapRequest.jsm", + "TorLauncherUtil.jsm", + "TorMonitorService.jsm", + "TorParsers.jsm", + "TorProcess.jsm", + "TorProtocolService.jsm", + "TorStartupService.jsm", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXTRA_COMPONENTS += [ + "tor-launcher.manifest", +] diff --git a/toolkit/components/tor-launcher/tor-launcher.manifest b/toolkit/components/tor-launcher/tor-launcher.manifest new file mode 100644 index 000000000000..649f3419e825 --- /dev/null +++ b/toolkit/components/tor-launcher/tor-launcher.manifest @@ -0,0 +1 @@ +category profile-after-change TorStartupService @torproject.org/tor-startup-service;1