Repository: freedomofpress/securedrop

Issue:
Add  v2 and v3 source interface addresses to metadata endpoint
## Description

The Source Interface exposes  a `/metadata` endpoint that includes information about an instance's OS and application version, supported languages, and submission key fingerprint, which is useful for Nagios monitoring purposes among other things. Adding the SI addresses in this endpoint would a) allow FPF to monitor v3 service uptake and update Nagios checks accordingly, and b) allow end users on the v2 version of  service to verify the correct v3 address for the service (as an alternative or supplement to automatic redirection via the Alt-SVC header).

Potential downside:  if an admin turns on v3 but doesn't want to advertise that they've done so, this could inadvertently expose the v3 address. 

## User Research Evidence

I have none but folks seem to like the idea on Gitter.

## User Stories
- As an FPF support team member, I'd like to be able to have v3 service information available for monitoring purposes
- as a SecureDrop user, I'd like to be able to verify the correct v3 address corresponding to a v2 address.



Discussion:

Resolving patch:
diff --git a/securedrop/source_app/api.py b/securedrop/source_app/api.py
--- a/securedrop/source_app/api.py
+++ b/securedrop/source_app/api.py
@@ -3,6 +3,8 @@
 
 from flask import Blueprint, current_app, make_response
 
+from source_app.utils import get_sourcev2_url, get_sourcev3_url
+
 import version
 
 
@@ -16,7 +18,9 @@ def metadata():
             'gpg_fpr': config.JOURNALIST_KEY,
             'sd_version': version.__version__,
             'server_os': platform.linux_distribution()[1],
-            'supported_languages': config.SUPPORTED_LOCALES
+            'supported_languages': config.SUPPORTED_LOCALES,
+            'v2_source_url': get_sourcev2_url(),
+            'v3_source_url': get_sourcev3_url()
         }
         resp = make_response(json.dumps(meta))
         resp.headers['Content-Type'] = 'application/json'
diff --git a/securedrop/source_app/utils.py b/securedrop/source_app/utils.py
--- a/securedrop/source_app/utils.py
+++ b/securedrop/source_app/utils.py
@@ -9,6 +9,7 @@
 from threading import Thread
 
 import i18n
+import re
 
 from crypto_util import CryptoException
 from models import Source
@@ -112,3 +113,31 @@ def normalize_timestamps(filesystem_id):
                 "Couldn't normalize submission "
                 "timestamps (touch exited with %d)" %
                 rc)
+
+
+def check_url_file(path, regexp):
+    """
+    Check that a file exists at the path given and contains a single line
+    matching the regexp. Used for checking the source interface address
+    files at /var/lib/securedrop/source_{v2,v3}_url.
+    """
+    try:
+        f = open(path, "r")
+        contents = f.readline().strip()
+        f.close()
+        if re.match(regexp, contents):
+            return contents
+        else:
+            return None
+    except IOError:
+        return None
+
+
+def get_sourcev2_url():
+    return check_url_file("/var/lib/securedrop/source_v2_url",
+                          r"^[a-z0-9]{16}\.onion$")
+
+
+def get_sourcev3_url():
+    return check_url_file("/var/lib/securedrop/source_v3_url",
+                          r"^[a-z0-9]{56}\.onion$")


Test patch:
diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py
--- a/securedrop/tests/test_source.py
+++ b/securedrop/tests/test_source.py
@@ -558,6 +558,32 @@ def test_metadata_route(config, source_app):
             assert resp.json.get('server_os') == '16.04'
             assert resp.json.get('supported_languages') ==\
                 config.SUPPORTED_LOCALES
+            assert resp.json.get('v2_source_url') is None
+            assert resp.json.get('v3_source_url') is None
+
+
+def test_metadata_v2_url(config, source_app):
+    onion_test_url = "abcdabcdabcdabcd.onion"
+    with patch.object(source_app_api, "get_sourcev2_url") as mocked_v2_url:
+        mocked_v2_url.return_value = (onion_test_url)
+        with source_app.test_client() as app:
+            resp = app.get(url_for('api.metadata'))
+            assert resp.status_code == 200
+            assert resp.headers.get('Content-Type') == 'application/json'
+            assert resp.json.get('v2_source_url') == onion_test_url
+            assert resp.json.get('v3_source_url') is None
+
+
+def test_metadata_v3_url(config, source_app):
+    onion_test_url = "abcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefgh.onion"
+    with patch.object(source_app_api, "get_sourcev3_url") as mocked_v3_url:
+        mocked_v3_url.return_value = (onion_test_url)
+        with source_app.test_client() as app:
+            resp = app.get(url_for('api.metadata'))
+            assert resp.status_code == 200
+            assert resp.headers.get('Content-Type') == 'application/json'
+            assert resp.json.get('v2_source_url') is None
+            assert resp.json.get('v3_source_url') == onion_test_url
 
 
 def test_login_with_overly_long_codename(source_app):
diff --git a/securedrop/tests/test_source_utils.py b/securedrop/tests/test_source_utils.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/test_source_utils.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+import os
+
+from source_app.utils import check_url_file
+
+
+def test_check_url_file(config):
+
+    assert check_url_file("nosuchfile", "whatever") is None
+
+    try:
+        def write_url_file(path, content):
+            url_file = open(path, "w")
+            url_file.write("{}\n".format(content))
+
+        url_path = "test_source_url"
+
+        onion_test_url = "abcdabcdabcdabcd.onion"
+        write_url_file(url_path, onion_test_url)
+        assert check_url_file(url_path, r"^[a-z0-9]{16}\.onion$") == onion_test_url
+
+        onion_test_url = "abcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefgh.onion"
+        write_url_file(url_path, onion_test_url)
+        assert check_url_file(url_path, r"^[a-z0-9]{56}\.onion$") == onion_test_url
+
+        write_url_file(url_path, "NO.onion")
+        assert check_url_file(url_path, r"^[a-z0-9]{56}\.onion$") is None
+    finally:
+        if os.path.exists(url_path):
+            os.unlink(url_path)