Repository: freedomofpress/securedrop

Issue:
On the Admin Workstation,  N SecureDrop updater processes are started if N network interfaces are enabled in Tails
## Description

If a Tails Admin Workstation has more than one network interface, the SecureDrop network manager hook that checks for updates will run for each active NIC. (For example, if a workstation has both Ethernet and Wifi enabled.)

This is confusing to end users and may result in multiple update processes clobbering each other's changes.

## Steps to Reproduce

On an Admin Workstation:
- revert to an earlier SecureDrop version:
```
cd ~/Persistent/securedrop
git checkout 0.10.0
```
- enable multiple network connections (eg Ethernet and Wifi) and wait for their Tor connections to come up

## Expected Behavior
A single instance of the Securedrop Updater is started.

## Actual Behavior
Multiple instances of the SecureDrop Updater are started.



Discussion:
https://tendo.readthedocs.io/en/latest/ can help us to solve this one with minimal code change. @redshiftzero @emkll any comments?
Last commit is 6 months ago, only 79 stars on GitHub - how commonly used is this dependency? Note that we use [this method](https://github.com/freedomofpress/securedrop-client/blob/master/securedrop_client/app.py#L113) to prevent duplicate instances of the securedrop-client, what do you think about using a similar approach here? 
>Last commit is 6 months ago, only 79 stars on GitHub - how commonly used is this dependency? Note that we use this method to prevent duplicate instances of the securedrop-client, what do you think about using a similar approach here?

I was going to comment about too, we just simply use the same function here.

Resolving patch:
diff --git a/journalist_gui/journalist_gui/SecureDropUpdater.py b/journalist_gui/journalist_gui/SecureDropUpdater.py
--- a/journalist_gui/journalist_gui/SecureDropUpdater.py
+++ b/journalist_gui/journalist_gui/SecureDropUpdater.py
@@ -5,6 +5,8 @@
 import os
 import re
 import pexpect
+import socket
+import sys
 
 from journalist_gui import updaterUI, strings, resources_rc  # noqa
 
@@ -13,6 +15,25 @@
 ESCAPE_POD = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]')
 
 
+def prevent_second_instance(app: QtWidgets.QApplication, name: str) -> None:  # noqa
+
+    # Null byte triggers abstract namespace
+    IDENTIFIER = '\0' + name
+    ALREADY_BOUND_ERRNO = 98
+
+    app.instance_binding = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
+    try:
+        app.instance_binding.bind(IDENTIFIER)
+    except OSError as e:
+        if e.errno == ALREADY_BOUND_ERRNO:
+            err_dialog = QtWidgets.QMessageBox()
+            err_dialog.setText(name + ' is already running.')
+            err_dialog.exec()
+            sys.exit()
+        else:
+            raise
+
+
 class SetupThread(QThread):
     signal = pyqtSignal('PyQt_PyObject')
 


Test patch:
diff --git a/journalist_gui/test_gui.py b/journalist_gui/test_gui.py
--- a/journalist_gui/test_gui.py
+++ b/journalist_gui/test_gui.py
@@ -1,6 +1,7 @@
 import unittest
 import subprocess
 import pexpect
+import pytest
 from unittest import mock
 from unittest.mock import MagicMock
 from PyQt5.QtCore import Qt
@@ -8,6 +9,54 @@
 from PyQt5.QtTest import QTest
 
 from journalist_gui.SecureDropUpdater import UpdaterApp, strings, FLAG_LOCATION
+from journalist_gui.SecureDropUpdater import prevent_second_instance
+
+
+@mock.patch('journalist_gui.SecureDropUpdater.sys.exit')
+@mock.patch('journalist_gui.SecureDropUpdater.QtWidgets.QMessageBox')
+class TestSecondInstancePrevention(unittest.TestCase):
+    def setUp(self):
+        self.mock_app = mock.MagicMock()
+        self.mock_app.applicationName = mock.MagicMock(return_value='sd')
+
+    @staticmethod
+    def socket_mock_generator(already_bound_errno=98):
+        namespace = set()
+
+        def kernel_bind(addr):
+            if addr in namespace:
+                error = OSError()
+                error.errno = already_bound_errno
+                raise error
+            else:
+                namespace.add(addr)
+
+        socket_mock = mock.MagicMock()
+        socket_mock.socket().bind = mock.MagicMock(side_effect=kernel_bind)
+        return socket_mock
+
+    def test_diff_name(self, mock_msgbox, mock_exit):
+        mock_socket = self.socket_mock_generator()
+        with mock.patch('journalist_gui.SecureDropUpdater.socket', new=mock_socket):
+            prevent_second_instance(self.mock_app, 'name1')
+            prevent_second_instance(self.mock_app, 'name2')
+
+            mock_exit.assert_not_called()
+
+    def test_same_name(self, mock_msgbox, mock_exit):
+        mock_socket = self.socket_mock_generator()
+        with mock.patch('journalist_gui.SecureDropUpdater.socket', new=mock_socket):
+            prevent_second_instance(self.mock_app, 'name1')
+            prevent_second_instance(self.mock_app, 'name1')
+
+            mock_exit.assert_any_call()
+
+    def test_unknown_kernel_error(self, mock_msgbox, mock_exit):
+        mock_socket = self.socket_mock_generator(131)  # crazy unexpected error
+        with mock.patch('journalist_gui.SecureDropUpdater.socket', new=mock_socket):
+            with pytest.raises(OSError):
+                prevent_second_instance(self.mock_app, 'name1')
+                prevent_second_instance(self.mock_app, 'name1')
 
 
 class AppTestCase(unittest.TestCase):
@@ -184,7 +233,7 @@ def test_tails_status_success(self):
                   'failure_reason': ''}
 
         with mock.patch('os.remove') as mock_remove:
-                    self.window.tails_status(result)
+            self.window.tails_status(result)
 
         # We do remove the flag file if the update does finish
         mock_remove.assert_called_once_with(FLAG_LOCATION)
@@ -195,7 +244,7 @@ def test_tails_status_failure(self):
                   'failure_reason': '42'}
 
         with mock.patch('os.remove') as mock_remove:
-                    self.window.tails_status(result)
+            self.window.tails_status(result)
 
         # We do not remove the flag file if the update does not finish
         mock_remove.assert_not_called()