Translation updates
[torbutton.git] / chrome / content / tor-circuit-display.js
blob76fb74546ac6bc6eda1657254696b5a868237f82
1 // A script that automatically displays the Tor Circuit used for the
2 // current domain for the currently selected tab.
3 //
4 // This file is written in call stack order (later functions
5 // call earlier functions). The file can be processed
6 // with docco.js to produce pretty documentation.
7 //
8 // This script is to be embedded in torbutton.xhtml. It defines a single global
9 // function, createTorCircuitDisplay(), which activates the automatic Tor
10 // circuit display for the current tab and any future tabs.
12 // See https://trac.torproject.org/8641
14 /* jshint esnext: true */
15 /* global document, gBrowser, Components */
17 // ### Main function
18 // __createTorCircuitDisplay(enablePrefName)__.
19 // The single function that prepares tor circuit display. Connects to a tor
20 // control port using information provided to the control port module via
21 // a previous call to configureControlPortModule(), and binds to a named
22 // bool pref whose value determines whether the circuit display is enabled
23 // or disabled.
24 let createTorCircuitDisplay = (function() {
25   "use strict";
27   // Mozilla utilities
28   const { Services } = ChromeUtils.import(
29     "resource://gre/modules/Services.jsm"
30   );
32   // Import the controller code.
33   const { wait_for_controller } = ChromeUtils.import(
34     "resource://torbutton/modules/tor-control-port.js"
35   );
37   // Utility functions
38   let {
39     bindPrefAndInit,
40     observe,
41     getLocale,
42     getDomainForBrowser,
43     torbutton_get_property_string,
44   } = ChromeUtils.import("resource://torbutton/modules/utils.js");
46   // Make the TorButton logger available.
47   let logger = Cc["@torproject.org/torbutton-logger;1"].getService(
48     Ci.nsISupports
49   ).wrappedJSObject;
51   // ## Circuit/stream credentials and node monitoring
53   // A mutable map that stores the current nodes for each
54   // SOCKS username/password pair.
55   let credentialsToNodeDataMap = new Map(),
56     // A mutable map that reports `true` for IDs of "mature" circuits
57     // (those that have conveyed a stream).
58     knownCircuitIDs = new Map(),
59     // A mutable map that records the SOCKS credentials for the
60     // latest channels for each browser + domain.
61     browserToCredentialsMap = new Map();
63   // __trimQuotes(s)__.
64   // Removes quotation marks around a quoted string.
65   let trimQuotes = s => (s ? s.match(/^"(.*)"$/)[1] : undefined);
67   // __getBridge(id)__.
68   // Gets the bridge parameters for a given node ID. If the node
69   // is not currently used as a bridge, returns null.
70   let getBridge = async function(controller, id) {
71     let bridges = await controller.getConf("bridge");
72     if (bridges) {
73       for (let bridge of bridges) {
74         if (bridge.ID && bridge.ID.toUpperCase() === id.toUpperCase()) {
75           return bridge;
76         }
77       }
78     }
79     return null;
80   };
82   // nodeDataForID(controller, id)__.
83   // Returns the type, IP addresses and country code of a node with given ID.
84   // Example: `nodeDataForID(controller, "20BC91DC525C3DC9974B29FBEAB51230DE024C44")`
85   // => `{ type: "default", ipAddrs: ["12.23.34.45", "2001:db8::"], countryCode: "fr" }`
86   let nodeDataForID = async function(controller, id) {
87     let result = { ipAddrs: [] };
88     const bridge = await getBridge(controller, id); // type, ip, countryCode;
89     const addrRe = /^\[?([^\]]+)\]?:\d+$/;
90     if (bridge) {
91       result.type = "bridge";
92       result.bridgeType = bridge.type;
93       // Attempt to get an IP address from bridge address string.
94       try {
95         const ip = bridge.address.match(addrRe)[1];
96         if (!ip.startsWith("0.")) {
97           result.ipAddrs = [ip];
98         }
99       } catch (e) {}
100     } else {
101       // either dealing with a relay, or a bridge whose fingerprint is not saved in torrc
102       try {
103         const statusMap = await controller.getInfo("ns/id/" + id);
104         result.type = "default";
105         if (!statusMap.IP.startsWith("0.")) {
106           result.ipAddrs.push(statusMap.IP);
107         }
108         try {
109           result.ipAddrs.push(statusMap.IPv6.match(addrRe)[1]);
110         } catch (e) {}
111       } catch (e) {
112         // getInfo will throw if the given id is not a relay
113         // this probably means we are dealing with a user-provided bridge with no fingerprint
114         result.type = "bridge";
115         // we don't know the ip/ipv6 or type, so leave blank
116         result.ipAddrs = [];
117         result.bridgeType = "";
118       }
119     }
120     if (result.ipAddrs.length) {
121       // Get the country code for the node's IP address.
122       try {
123         const countryCode = await controller.getInfo(
124           "ip-to-country/" + result.ipAddrs[0]
125         );
126         result.countryCode = countryCode === "??" ? null : countryCode;
127       } catch (e) {}
128     }
129     return result;
130   };
132   // __nodeDataForCircuit(controller, circuitEvent)__.
133   // Gets the information for a circuit.
134   let nodeDataForCircuit = async function(controller, circuitEvent) {
135     let rawIDs = circuitEvent.circuit.map(circ => circ[0]),
136       // Remove the leading '$' if present.
137       ids = rawIDs.map(id => (id[0] === "$" ? id.substring(1) : id));
138     // Get the node data for all IDs in circuit.
139     return Promise.all(ids.map(id => nodeDataForID(controller, id)));
140   };
142   // __getCircuitStatusByID(aController, circuitID)__
143   // Returns the circuit status for the circuit with the given ID.
144   let getCircuitStatusByID = async function(aController, circuitID) {
145     let circuitStatuses = await aController.getInfo("circuit-status");
146     if (circuitStatuses) {
147       for (let circuitStatus of circuitStatuses) {
148         if (circuitStatus.id === circuitID) {
149           return circuitStatus;
150         }
151       }
152     }
153     return null;
154   };
156   // __collectIsolationData(aController, updateUI)__.
157   // Watches for STREAM SENTCONNECT events. When a SENTCONNECT event occurs, then
158   // we assume isolation settings (SOCKS username+password) are now fixed for the
159   // corresponding circuit. Whenever the first stream on a new circuit is seen,
160   // looks up u+p and records the node data in the credentialsToNodeDataMap.
161   // We need to update the circuit display immediately after any new node data
162   // is received. So the `updateUI` callback will be called at that point.
163   // See https://trac.torproject.org/projects/tor/ticket/15493
164   let collectIsolationData = function(aController, updateUI) {
165     return aController.watchEvent(
166       "STREAM",
167       streamEvent => streamEvent.StreamStatus === "SENTCONNECT",
168       async streamEvent => {
169         if (!knownCircuitIDs.get(streamEvent.CircuitID)) {
170           logger.eclog(3, "streamEvent.CircuitID: " + streamEvent.CircuitID);
171           knownCircuitIDs.set(streamEvent.CircuitID, true);
172           let circuitStatus = await getCircuitStatusByID(
173               aController,
174               streamEvent.CircuitID
175             ),
176             credentials = circuitStatus
177               ? trimQuotes(circuitStatus.SOCKS_USERNAME) +
178                 "|" +
179                 trimQuotes(circuitStatus.SOCKS_PASSWORD)
180               : null;
181           if (credentials) {
182             let nodeData = await nodeDataForCircuit(aController, circuitStatus);
183             credentialsToNodeDataMap.set(credentials, nodeData);
184             updateUI();
185           }
186         }
187       }
188     );
189   };
191   // __browserForChannel(channel)__.
192   // Returns the browser that loaded a given channel.
193   let browserForChannel = function(channel) {
194     if (!channel) {
195       return null;
196     }
197     let chan = channel.QueryInterface(Ci.nsIChannel);
198     let callbacks = chan.notificationCallbacks;
199     if (!callbacks) {
200       return null;
201     }
202     let loadContext;
203     try {
204       loadContext = callbacks.getInterface(Ci.nsILoadContext);
205     } catch (e) {
206       // Ignore
207       return null;
208     }
209     if (!loadContext) {
210       return null;
211     }
212     return loadContext.topFrameElement;
213   };
215   // __collectBrowserCredentials()__.
216   // Starts observing http channels. Each channel's proxyInfo
217   // username and password is recorded for the channel's browser.
218   let collectBrowserCredentials = function() {
219     return observe("http-on-modify-request", chan => {
220       try {
221         let proxyInfo = chan.QueryInterface(Ci.nsIProxiedChannel).proxyInfo;
222         let browser = browserForChannel(chan);
223         if (browser && proxyInfo) {
224           if (!browserToCredentialsMap.has(browser)) {
225             browserToCredentialsMap.set(browser, new Map());
226           }
227           let domainMap = browserToCredentialsMap.get(browser);
228           domainMap.set(proxyInfo.username, [
229             proxyInfo.username,
230             proxyInfo.password,
231           ]);
232         }
233       } catch (e) {
234         logger.eclog(
235           3,
236           `Error collecting browser credentials: ${e.message}, ${chan.URI.spec}`
237         );
238       }
239     });
240   };
242   // ## User interface
244   // __uiString__.
245   // Read the localized strings for this UI.
246   let uiString = function(shortName) {
247     return torbutton_get_property_string(
248       "torbutton.circuit_display." + shortName
249     );
250   };
252   // __localizedCountryNameFromCode(countryCode)__.
253   // Convert a country code to a localized country name.
254   // Example: `'de'` -> `'Deutschland'` in German locale.
255   let localizedCountryNameFromCode = function(countryCode) {
256     if (!countryCode) {
257       return uiString("unknown_country");
258     }
259     try {
260       return Services.intl.getRegionDisplayNames(undefined, [countryCode])[0];
261     } catch (e) {
262       return countryCode.toUpperCase();
263     }
264   };
266   // __showCircuitDisplay(show)__.
267   // If show === true, makes the circuit display visible.
268   let showCircuitDisplay = function(show) {
269     document.getElementById("circuit-display-container").hidden = !show;
270   };
272   // __xmlTree(ns, data)__.
273   // Takes an xml namespace, ns, and a
274   // data structure representing xml elements like
275   // [tag, { attr-key: attr-value }, ...xml-children]
276   // and returns nested xml element objects.
277   let xmlTree = function xmlTree(ns, data) {
278     let [type, attrs, ...children] = data;
279     let element = type.startsWith("html:")
280       ? document.createXULElement(type)
281       : document.createElementNS(ns, type);
282     for (let [key, val] of Object.entries(attrs)) {
283       element.setAttribute(key, val);
284     }
285     for (let child of children) {
286       if (child !== null && child !== undefined) {
287         element.append(typeof child === "string" ? child : xmlTree(ns, child));
288       }
289     }
290     return element;
291   };
293   // __htmlTree(data)__.
294   // Takes a data structure representing html elements like
295   // [tag, { attr-key: attr-value }, ...html-children]
296   // and returns nested html element objects.
297   let htmlTree = data => xmlTree("http://www.w3.org/1999/xhtml", data);
299   // __appendHtml(parent, data)__.
300   // Takes a data structure representing html elements like
301   // [tag, { attr-key: attr-value }, ...html-children]
302   // and appends nested html element objects to the parent element.
303   let appendHtml = (parent, data) => parent.appendChild(htmlTree(data));
305   // __circuitCircuitData()__.
306   // Obtains the circuit used by the given browser.
307   let currentCircuitData = function(browser) {
308     if (browser) {
309       let firstPartyDomain = getDomainForBrowser(browser);
310       let domain = firstPartyDomain || "--unknown--";
311       let domainMap = browserToCredentialsMap.get(browser);
312       let credentials = domainMap && domainMap.get(domain);
313       if (credentials) {
314         let [SOCKS_username, SOCKS_password] = credentials;
315         let nodeData = credentialsToNodeDataMap.get(
316           `${SOCKS_username}|${SOCKS_password}`
317         );
318         let domain = SOCKS_username;
319         if (browser.documentURI.host.endsWith(".tor.onion")) {
320           const service = Cc[
321             "@torproject.org/onion-alias-service;1"
322           ].getService(Ci.IOnionAliasService);
323           domain = service.getOnionAlias(browser.documentURI.host);
324         }
325         return { domain, nodeData };
326       }
327     }
328     return { domain: null, nodeData: null };
329   };
331   // __updateCircuitDisplay()__.
332   // Updates the Tor circuit display, showing the current domain
333   // and the relay nodes for that domain.
334   let updateCircuitDisplay = function() {
335     let { domain, nodeData } = currentCircuitData(gBrowser.selectedBrowser);
336     if (domain && nodeData) {
337       // Update the displayed information for the relay nodes.
338       let nodeHtmlList = document.getElementById("circuit-display-nodes");
339       let li = (...data) => appendHtml(nodeHtmlList, ["li", {}, ...data]);
340       nodeHtmlList.innerHTML = "";
341       li(uiString("this_browser"));
342       for (let i = 0; i < nodeData.length; ++i) {
343         let relayText;
344         if (nodeData[i].type === "bridge") {
345           relayText = uiString("tor_bridge");
346           let bridgeType = nodeData[i].bridgeType;
347           if (bridgeType === "meek_lite") {
348             relayText += ": meek";
349           } else if (bridgeType !== "vanilla" && bridgeType !== "") {
350             relayText += ": " + bridgeType;
351           }
352         } else if (nodeData[i].type == "default") {
353           relayText = localizedCountryNameFromCode(nodeData[i].countryCode);
354         }
355         const ipAddrs = nodeData[i].ipAddrs.join(", ");
356         li(
357           relayText,
358           " ",
359           ["span", { class: "circuit-ip-address" }, ipAddrs],
360           " ",
361           i === 0 && nodeData[0].type !== "bridge"
362             ? ["span", { class: "circuit-guard-info" }, uiString("guard")]
363             : null
364         );
365       }
367       let domainParts = [];
368       if (domain.endsWith(".onion")) {
369         for (let i = 0; i < 3; ++i) {
370           li(uiString("relay"));
371         }
372         if (domain.length > 22) {
373           domainParts.push(domain.slice(0, 7), "…", domain.slice(-12));
374         } else {
375           domainParts.push(domain);
376         }
377       } else {
378         domainParts.push(domain);
379       }
381       // We use a XUL html:span element so that the tooltiptext is displayed.
382       li([
383         "html:span",
384         {
385           class: "circuit-onion",
386           onclick: `
387           this.classList.add("circuit-onion-copied");
388           Cc[
389             "@mozilla.org/widget/clipboardhelper;1"
390           ].getService(Ci.nsIClipboardHelper).copyString(this.getAttribute("data-onion"))
391         `,
392           "data-onion": domain,
393           "data-text-clicktocopy": torbutton_get_property_string(
394             "torbutton.circuit_display.click_to_copy"
395           ),
396           "data-text-copied": torbutton_get_property_string(
397             "torbutton.circuit_display.copied"
398           ),
399           tooltiptext: domain,
400         },
401         ...domainParts,
402       ]);
404       // Hide the note about guards if we are using a bridge.
405       document.getElementById("circuit-guard-note-container").hidden =
406         nodeData[0].type === "bridge";
407     } else {
408       // Only show the Tor circuit if we have credentials and node data.
409       logger.eclog(4, "no SOCKS credentials found for current document.");
410     }
411     showCircuitDisplay(domain && nodeData);
412   };
414   // __syncDisplayWithSelectedTab(syncOn)__.
415   // Whenever the user starts to open the popup menu, make sure the display
416   // is the correct one for this tab. It's also possible that a new site
417   // can be loaded while the popup menu is open.
418   // Update the display if this happens.
419   let syncDisplayWithSelectedTab = (function() {
420     let listener = {
421       onLocationChange(aBrowser) {
422         if (aBrowser === gBrowser.selectedBrowser) {
423           updateCircuitDisplay();
424         }
425       },
426     };
427     return function(syncOn) {
428       let popupMenu = document.getElementById("identity-popup");
429       if (syncOn) {
430         // Update the circuit display just before the popup menu is shown.
431         popupMenu.addEventListener("popupshowing", updateCircuitDisplay);
432         // If the currently selected tab has been sent to a new location,
433         // update the circuit to reflect that.
434         gBrowser.addTabsProgressListener(listener);
435       } else {
436         // Stop syncing.
437         gBrowser.removeTabsProgressListener(listener);
438         popupMenu.removeEventListener("popupshowing", updateCircuitDisplay);
439         // Hide the display.
440         showCircuitDisplay(false);
441       }
442     };
443   })();
445   // __setupGuardNote()__.
446   // Call once to show the Guard note as intended.
447   let setupGuardNote = function() {
448     let guardNote = document.getElementById("circuit-guard-note-container");
449     let guardNoteString = uiString("guard_note");
450     let learnMoreString = uiString("learn_more");
451     let [noteBefore, name, noteAfter] = guardNoteString.split(/[\[\]]/);
452     let localeCode = getLocale();
453     appendHtml(guardNote, [
454       "div",
455       {},
456       noteBefore,
457       ["span", { class: "circuit-guard-name" }, name],
458       noteAfter,
459       " ",
460       [
461         "span",
462         {
463           onclick: `gBrowser.selectedTab = gBrowser.addWebTab('https://support.torproject.org/${localeCode}/tbb/tbb-2/');`,
464           class: "circuit-link",
465         },
466         learnMoreString,
467       ],
468     ]);
469   };
471   // __ensureCorrectPopupDimensions()__.
472   // Make sure the identity popup always displays with the correct height.
473   let ensureCorrectPopupDimensions = function() {
474     // FIXME: This is hacking with the internals of the panel view, which also
475     // sets the width and height for transitions (see PanelMultiView.jsm), so
476     // this fix is fragile.
477     // Ideally the panel view would start using a non-XUL CSS layout, which
478     // would work regardless of the content.
479     let popupMenu = document.getElementById("identity-popup");
480     let setDimensions = event => {
481       if (event.target !== popupMenu) {
482         return;
483       }
484       setTimeout(() => {
485         let view = document.querySelector(
486           "#identity-popup-multiView .panel-viewcontainer"
487         );
488         let stack = document.querySelector(
489           "#identity-popup-multiView .panel-viewstack"
490         );
491         let view2 = document.getElementById("identity-popup-mainView");
492         if (view && stack && view2) {
493           let newWidth = Math.max(
494             ...[...view2.children].map(el => el.clientWidth)
495           );
496           let newHeight = stack.clientHeight;
497           stack.setAttribute("width", newWidth);
498           view2.style.minWidth = view2.style.maxWidth = newWidth + "px";
499           view.setAttribute("width", newWidth);
500           view.setAttribute("height", newHeight);
501         }
502       });
503     };
504     let removeDimensions = event => {
505       if (event.target !== popupMenu) {
506         return;
507       }
508       let view = document.querySelector(
509         "#identity-popup-multiView .panel-viewcontainer"
510       );
511       let stack = document.querySelector(
512         "#identity-popup-multiView .panel-viewstack"
513       );
514       let view2 = document.getElementById("identity-popup-mainView");
515       if (view && stack && view2) {
516         view.removeAttribute("width");
517         view.removeAttribute("height");
518         stack.removeAttribute("width");
519         view2.style.minWidth = view2.style.maxWidth = "";
520       }
521     };
522     popupMenu.addEventListener("popupshowing", setDimensions);
523     popupMenu.addEventListener("popuphiding", removeDimensions);
524     return () => {
525       popupMenu.removeEventListener("popupshowing", setDimensions);
526       popupMenu.removeEventListener("popuphiding", removeDimensions);
527     };
528   };
530   // ## Main function
532   // __setupDisplay(enablePrefName)__.
533   // Once called, the Tor circuit display will be started whenever
534   // the "enablePref" is set to true, and stopped when it is set to false.
535   // A reference to this function (called createTorCircuitDisplay) is exported as a global.
536   let setupDisplay = function(enablePrefName) {
537     // From 79 on the identity popup is initialized lazily
538     if (gIdentityHandler._initializePopup) {
539       gIdentityHandler._initializePopup();
540     }
541     setupGuardNote();
542     let myController = null,
543       stopCollectingIsolationData = null,
544       stopCollectingBrowserCredentials = null,
545       stopEnsuringCorrectPopupDimensions = null,
546       stop = function() {
547         syncDisplayWithSelectedTab(false);
548         if (myController) {
549           if (stopCollectingIsolationData) {
550             stopCollectingIsolationData();
551           }
552           if (stopCollectingBrowserCredentials) {
553             stopCollectingBrowserCredentials();
554           }
555           if (stopEnsuringCorrectPopupDimensions) {
556             stopEnsuringCorrectPopupDimensions();
557           }
558           myController = null;
559         }
560       },
561       start = async function() {
562         if (!myController) {
563           try {
564             myController = await wait_for_controller();
565             syncDisplayWithSelectedTab(true);
566             stopCollectingIsolationData = collectIsolationData(
567               myController,
568               updateCircuitDisplay
569             );
570             stopCollectingBrowserCredentials = collectBrowserCredentials();
571             stopEnsuringCorrectPopupDimensions = ensureCorrectPopupDimensions();
572           } catch (err) {
573             logger.eclog(5, err);
574             logger.eclog(
575               5,
576               "Disabling tor display circuit because of an error."
577             );
578             myController.close();
579             stop();
580           }
581         }
582       };
583     try {
584       let unbindPref = bindPrefAndInit(enablePrefName, on => {
585         if (on) {
586           start();
587         } else {
588           stop();
589         }
590       });
591       // When this chrome window is unloaded, we need to unbind the pref.
592       window.addEventListener("unload", function() {
593         unbindPref();
594         stop();
595       });
596     } catch (e) {
597       logger.eclog(5, "Error: " + e.message + "\n" + e.stack);
598     }
599   };
601   return setupDisplay;
603   // Finish createTorCircuitDisplay()
604 })();