>> Motivation
I use Perfect Privacy VPN as an additional security and privacy layer for my internet traffic. While I was able to make it work consistently with OpenVPN by removing the persist-tun option, it was consistently being stopped by starting docker for my development environment. Although I have not found the correct fix yet, I want to be visually alerted while browsing with Firefox whether my VPN is down by checking this page or json endpoint. A browser extension polling the endpoint could alert me to switch to a privacy browser like Tor Browser or Brave Browser and possibly restart my VPN. One caveat with this approach is browser fingerprinting; however, the extension is simple enough not to interfere with the browser to be detected but must be kept in mind when creating extensions.
(The extension can be downloaded here and source code found here.)
>> Background
Before proceeding, do read Mozilla's first extension guide as the focus will be on background scripts. The first step in creating the extension is declaring a manifest.json similar to a package.json:
// manifest.json
{
"manifest_version": 2,
"name": "Perfect Privacy Checker",
"version": "1.0.3",
"description": "Checks if browser is connected to Perfect Privacy VPN",
"homepage_url": "https://github.com/FrancisMurillo/perfect-privacy-extension/tree/master/perfect-privacy"
}
To declare a background script that will poll the endpoint, the manifest background property should declare the startup scripts:
// manifest.json
{
// ...
"background": {
"scripts": ["background.js"]
}
}
Before creating the background script, a reminder of the JSON output to be handled:
// https://checkip.perfect-privacy.com/json
// Prettified and scrubbed JSON output
{
"VPN": true,
"TOR": false,
"IP": "11.222.3.44",
"DNS": "nowhere2.perfect-privacy.com",
"CITY": "NOWHERE",
"COUNTRY": "ANYWHERE"
}
Using only plain Javascript to fetch data from that endpoint via
XMLHttpRequest, the initial background.js
looks like:
// background.js
// Store/cachea result from the endpoint
var info = null;
// Check connectivity via JSON XHR
function checkStatus() {
try {
var xhr = new XMLHttpRequest();
xhr.open("GET", "https://checkip.perfect-privacy.com/json", false);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onreadystatechange = function() {
if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
info = JSON.parse(this.responseText);
} else {
info = null;
}
updateStatus();
}
xhr.send(null);
} catch (ex) {
// On network error, reset data
info = null;
updateStatus();
}
}
// TODO: Notify extension on status
function updateStatus() { }
// Check on startup
checkStatus()
Since the extension makes a cross-domain request, the domain must be added to the manifest permissions to allow this:
// manifest.json
{
// ...
"permissions": [
"webRequest",
"https://checkip.perfect-privacy.com/*"
]
}
With the core of the extension above, when does the background script trigger the check? Rather than polling over an interval, checking when the browser is active or on focus is good enough. Looking over the potential browser events to use, I settled on tabs.onActivated, tabs.onUpdated and windows.onFocusChanged which covers my use case and more can be added if needed. Adding event listeners to those:
browser.tabs.onUpdated.addListener(checkStatus);
browser.tabs.onActivated.addListener(checkStatus);
browser.windows.onFocusChanged.addListener(checkStatus);
checkStatus();
Since the extension does not use privileged aspects of the browser API, no extra permissions are needed. With this approach though, one minor caveat is throttling the number of requests since each event will trigger one network call. Ideally, only one in-flight request should be allowed and perhaps throttling them every 30 seconds to avoid making unnecessary requests:
var isPending = false;
var lastTime = 0;
x// Sketching the `checkStatus` function for only the updated parts
function checkStatus() {
// Make sure only one request is running
if (!isPending) {
var now = new Date();
// Throttle requests for 30 seconds
if (now - lastTime >= 30000) {
lastTime = now;
} else {
return;
}
isPending = true;
try {
// ...
xhr.onreadystatechange = function() {
isPending = false;
/// ...
}
} catch (ex) {
isPending = false;
// ...
}
}
}
Before this extension can be tested, a user interface must be implemented to render the results.
>> Popup
Extension pages allow the extension to display an icon and on click HTML dialog or popup that is declared like so:
// manifest.json
{
// ...
"icons": {
"64": "icons/perfect-privacy-64.png"
},
"browser_action": {
"default_icon": "icons/perfect-privacy-64.png",
"default_title": "Connected to Perfect Privacy",
"default_popup": "popup/index.html"
}
}
When the 64x64 extension icon is clicked, it will render a simple column-value table based on the endpoint result. A quick implementation would be:
<!-- popup/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="index.css"/>
</head>
<body>
<table>
<tr>
<td class="label">IP</td>
<td class="value" id="ip-value">N/A</td>
</tr>
<tr>
<td class="label">DNS</td>
<td class="value" id="dns-value">N/A</td>
</tr>
<tr>
<td class="label">CITY</td>
<td class="value" id="city-value">N/A</td>
</tr>
<tr>
<td class="label">COUNTRY</td>
<td class="value" id="country-value">N/A</td>
</tr>
</table>
<script src="index.js"></script>
</body>
</html>
html, body {
height: 100px;
width: 320px;
margin: 0;
}
.label {
text-align: left;
}
.value {
text-align: right;
}
To test running this extension, install web-ext, run web-ext run
and
it will look like this:
Although not the fanciest interface and icon, the next question is how will the user interface access background script information?
>> Ports
Rather, the background script will push data updates for the user
interface to render via runtime.Ports or a socket-like connection.
Whenever the extension is clicked, the interface will attempt to
connect to the background script, then the background script will push
the info
variable:
// popup/index.js
// Connect to the background port
var port = browser.runtime.connect({name: "popup-port"});
// On update from the background, render it in the table
port.onMessage.addListener(function(info) {
info = info || {}
document.getElementById("ip-value").innerHTML = info.IP || "N/A"
document.getElementById("dns-value").innerHTML = info.DNS || "N/A"
document.getElementById("country-value").innerHTML = info.COUNTRY || "N/A"
document.getElementById("city-value").innerHTML = info.CITY || "N/A"
});
The background script must now handle connection event:
// background.js
// Store the active interface connection
var port = null;
// On interface connection, update the interface immediately
function connected(p) {
port = p;
updatePort();
}
browser.runtime.onConnect.addListener(connected);
// Simply send the stored `info` variable to the UI
function updatePort() {
if (port) {
port.postMessage(info);
}
}
//
function updateStatus() {
// If the VPN is connected, update the extension icon and title to be active or inactive
if (info && info.VPN) {
browser.browserAction.setTitle({ title: "Connected to Perfect Privacy" })
browser.browserAction.setIcon({ path: { 64: "icons/perfect-privacy-64.png" } });
} else {
browser.browserAction.setIcon({ path: { 64: "icons/perfect-privacy-64-inactive.png" } });
browser.browserAction.setTitle({ title: "Not Connected to Perfect Privacy" })
}
// Also update the interface
updatePort();
}
Lastly, add the runtime.onMessage and runtime.sendMessage permissions to allow this intercommunication:
// manifest.json
{
// ...
"permissions": [
// ...
"runtime.onMessage",
"runtime.sendMessage"
]
}
Finally, the extension should work and can now be installed:
>> References
Some minor references about this topic:
Installing unsigned extensions : If testing the extension locally is needed.
macro_railroad_ext : An example non-trivial cross-browser extension with web assembly.