diff --git a/plinth/modules/tor/__init__.py b/plinth/modules/tor/__init__.py
index 226db8854..a6fcaaab1 100644
--- a/plinth/modules/tor/__init__.py
+++ b/plinth/modules/tor/__init__.py
@@ -1,24 +1,29 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""FreedomBox app to configure Tor."""
+import logging
+
from django.utils.translation import gettext_lazy as _
from plinth import action_utils
from plinth import app as app_module
-from plinth import cfg, menu
+from plinth import menu
+from plinth import setup as setup_module
from plinth.daemon import (Daemon, app_is_running, diagnose_netcat,
diagnose_port_listening)
-from plinth.modules.apache.components import Webserver, diagnose_url
+from plinth.modules.apache.components import Webserver
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from plinth.modules.names.components import DomainType
+from plinth.modules.torproxy.utils import is_apt_transport_tor_enabled
from plinth.modules.users.components import UsersAndGroups
from plinth.package import Packages
from plinth.signals import domain_added, domain_removed
-from plinth.utils import format_lazy
from . import manifest, privileged, utils
+logger = logging.getLogger(__name__)
+
_description = [
_('Tor is an anonymous communication system. You can learn more '
'about it from the Tor '
@@ -26,9 +31,6 @@ _description = [
'Tor Project recommends that you use the '
''
'Tor Browser.'),
- format_lazy(
- _('A Tor SOCKS port is available on your {box_name} for internal '
- 'networks on TCP port 9050.'), box_name=_(cfg.box_name))
]
@@ -37,7 +39,7 @@ class TorApp(app_module.App):
app_id = 'tor'
- _version = 6
+ _version = 7
def __init__(self):
"""Create components for the app."""
@@ -57,28 +59,20 @@ class TorApp(app_module.App):
parent_url_name='apps')
self.add(menu_item)
- packages = Packages('packages-tor', [
- 'tor', 'tor-geoipdb', 'torsocks', 'obfs4proxy', 'apt-transport-tor'
- ])
+ packages = Packages('packages-tor',
+ ['tor', 'tor-geoipdb', 'obfs4proxy'])
self.add(packages)
domain_type = DomainType('domain-type-tor', _('Tor Onion Service'),
'tor:index', can_have_certificate=False)
self.add(domain_type)
- firewall = Firewall('firewall-tor-socks', _('Tor Socks Proxy'),
- ports=['tor-socks'], is_external=False)
- self.add(firewall)
-
firewall = Firewall('firewall-tor-relay', _('Tor Bridge Relay'),
ports=['tor-orport', 'tor-obfs3',
'tor-obfs4'], is_external=True)
self.add(firewall)
- daemon = Daemon(
- 'daemon-tor', 'tor@plinth', strict_check=True,
- listen_ports=[(9050, 'tcp4'), (9050, 'tcp6'), (9040, 'tcp4'),
- (9040, 'tcp6'), (9053, 'udp4'), (9053, 'udp6')])
+ daemon = Daemon('daemon-tor', 'tor@plinth', strict_check=True)
self.add(daemon)
webserver = Webserver('webserver-onion-location',
@@ -113,8 +107,7 @@ class TorApp(app_module.App):
update_hidden_service_domain()
def disable(self):
- """Disable APT use of Tor before disabling."""
- privileged.configure(apt_transport_tor=False)
+ """Disable the app and remove HS domain."""
super().disable()
update_hidden_service_domain()
@@ -166,21 +159,12 @@ class TorApp(app_module.App):
'passed' if len(hs_hostname) == 56 else 'failed'
])
- results.append(_diagnose_url_via_tor('http://www.debian.org', '4'))
- results.append(_diagnose_url_via_tor('http://www.debian.org', '6'))
-
- results.append(_diagnose_tor_use('https://check.torproject.org', '4'))
- results.append(_diagnose_tor_use('https://check.torproject.org', '6'))
-
return results
def setup(self, old_version):
"""Install and configure the app."""
super().setup(old_version)
privileged.setup(old_version)
- if not old_version:
- privileged.configure(apt_transport_tor=True)
-
update_hidden_service_domain(utils.get_status())
# Enable/disable Onion-Location component based on app status.
@@ -189,11 +173,26 @@ class TorApp(app_module.App):
daemon_component = self.get_component('daemon-tor')
component = self.get_component('webserver-onion-location')
if daemon_component.is_enabled():
+ logger.info('Enabling Onion-Location component')
component.enable()
else:
+ logger.info('Disabling Onion-Location component')
component.disable()
+ # The SOCKS proxy and "Download software packages using Tor" features
+ # were moved into a new app, Tor Proxy, in version 7. If the "Download
+ # software packages using Tor" option was enabled, then install and
+ # enable Tor Proxy, to avoid any issues for apt.
+ if old_version and old_version < 7:
+ if self.is_enabled() and is_apt_transport_tor_enabled():
+ logger.info(
+ 'Tor Proxy app will be installed for apt-transport-tor')
+ # This creates the operation, which will run after the current
+ # operation (Tor setup) is completed.
+ setup_module.run_setup_on_app('torproxy')
+
if not old_version:
+ logger.info('Enabling Tor app')
self.enable()
def uninstall(self):
@@ -234,23 +233,3 @@ def _diagnose_control_port():
negate=negate))
return results
-
-
-def _diagnose_url_via_tor(url, kind=None):
- """Diagnose whether a URL is reachable via Tor."""
- result = diagnose_url(url, kind=kind, wrapper='torsocks')
- result[0] = _('Access URL {url} on tcp{kind} via Tor') \
- .format(url=url, kind=kind)
-
- return result
-
-
-def _diagnose_tor_use(url, kind=None):
- """Diagnose whether webpage at URL reports that we are using Tor."""
- expected_output = 'Congratulations. This browser is configured to use Tor.'
- result = diagnose_url(url, kind=kind, wrapper='torsocks',
- expected_output=expected_output)
- result[0] = _('Confirm Tor usage at {url} on tcp{kind}') \
- .format(url=url, kind=kind)
-
- return result
diff --git a/plinth/modules/tor/forms.py b/plinth/modules/tor/forms.py
index cbf1551f2..d09684064 100644
--- a/plinth/modules/tor/forms.py
+++ b/plinth/modules/tor/forms.py
@@ -70,8 +70,8 @@ def bridges_validator(bridges):
raise validation_error
-class TorForm(forms.Form): # pylint: disable=W0232
- """Tor configuration form."""
+class TorCommonForm(forms.Form):
+ """Tor common configuration form."""
use_upstream_bridges = forms.BooleanField(
label=_('Use upstream bridges to connect to Tor network'),
required=False, help_text=_(
@@ -87,6 +87,10 @@ class TorForm(forms.Form): # pylint: disable=W0232
'https://bridges.torproject.org/ and copy/paste the bridge '
'information here. Currently supported transports are none, '
'obfs3, obfs4 and scamblesuit.'), validators=[bridges_validator])
+
+
+class TorForm(TorCommonForm):
+ """Tor configuration form."""
relay_enabled = forms.BooleanField(
label=_('Enable Tor relay'), required=False, help_text=format_lazy(
_('When enabled, your {box_name} will run a Tor relay and donate '
@@ -107,12 +111,6 @@ class TorForm(forms.Form): # pylint: disable=W0232
'services (such as wiki or chat) without revealing its '
'location. Do not use this for strong anonymity yet.'),
box_name=_(cfg.box_name)))
- apt_transport_tor_enabled = forms.BooleanField(
- label=_('Download software packages over Tor'), required=False,
- help_text=_('When enabled, software will be downloaded over the Tor '
- 'network for installations and upgrades. This adds a '
- 'degree of privacy and security during software '
- 'downloads.'))
def clean(self):
"""Validate the form for cross-field integrity."""
diff --git a/plinth/modules/tor/manifest.py b/plinth/modules/tor/manifest.py
index 0657b6c06..b2e0aa1bd 100644
--- a/plinth/modules/tor/manifest.py
+++ b/plinth/modules/tor/manifest.py
@@ -44,11 +44,11 @@ clients = [{
backup = {
'config': {
- 'directories': ['/etc/tor/'],
+ 'directories': ['/etc/tor/instances/plinth/'],
'files': [str(privileged.TOR_APACHE_SITE)]
},
'secrets': {
- 'directories': ['/var/lib/tor/', '/var/lib/tor-instances/']
+ 'directories': ['/var/lib/tor-instances/plinth/']
},
'services': ['tor@plinth']
}
diff --git a/plinth/modules/tor/privileged.py b/plinth/modules/tor/privileged.py
index 455068a63..31906ca7a 100644
--- a/plinth/modules/tor/privileged.py
+++ b/plinth/modules/tor/privileged.py
@@ -2,6 +2,7 @@
"""Configure Tor service."""
import codecs
+import logging
import os
import pathlib
import re
@@ -15,20 +16,29 @@ import augeas
from plinth import action_utils
from plinth.actions import privileged
-from plinth.modules.tor.utils import APT_TOR_PREFIX, get_augeas, iter_apt_uris
+INSTANCE_NAME = 'plinth'
SERVICE_FILE = '/etc/firewalld/services/tor-{0}.xml'
-TOR_CONFIG = '/files/etc/tor/instances/plinth/torrc'
-TOR_STATE_FILE = '/var/lib/tor-instances/plinth/state'
-TOR_AUTH_COOKIE = '/var/run/tor-instances/plinth/control.authcookie'
+SERVICE_NAME = f'tor@{INSTANCE_NAME}'
+TOR_CONFIG = f'/etc/tor/instances/{INSTANCE_NAME}/torrc'
+TOR_CONFIG_AUG = f'/files/{TOR_CONFIG}'
+TOR_STATE_FILE = f'/var/lib/tor-instances/{INSTANCE_NAME}/state'
+TOR_AUTH_COOKIE = f'/var/run/tor-instances/{INSTANCE_NAME}/control.authcookie'
TOR_APACHE_SITE = '/etc/apache2/conf-available/onion-location-freedombox.conf'
+logger = logging.getLogger(__name__)
+
@privileged
def setup(old_version: int):
"""Setup Tor configuration after installing it."""
- if old_version and old_version <= 4:
- _upgrade_orport_value()
+ if old_version:
+ if old_version <= 4:
+ _upgrade_orport_value()
+
+ if old_version <= 6:
+ _remove_proxy()
+
return
_first_time_setup()
@@ -37,50 +47,39 @@ def setup(old_version: int):
def _first_time_setup():
"""Setup Tor configuration for the first time setting defaults."""
+ logger.info('Performing first time setup for Tor')
# Disable default tor service. We will use tor@plinth instance
# instead.
- _disable_apt_transport_tor()
- action_utils.service_disable('tor')
+ action_utils.service_disable('tor@default')
- subprocess.run(['tor-instance-create', 'plinth'], check=True)
+ subprocess.run(['tor-instance-create', INSTANCE_NAME], check=True)
# Remove line starting with +SocksPort, since our augeas lens
# doesn't handle it correctly.
- with open('/etc/tor/instances/plinth/torrc', 'r',
- encoding='utf-8') as torrc:
+ with open(TOR_CONFIG, 'r', encoding='utf-8') as torrc:
torrc_lines = torrc.readlines()
- with open('/etc/tor/instances/plinth/torrc', 'w',
- encoding='utf-8') as torrc:
+ with open(TOR_CONFIG, 'w', encoding='utf-8') as torrc:
for line in torrc_lines:
if not line.startswith('+'):
torrc.write(line)
aug = augeas_load()
- aug.set(TOR_CONFIG + '/SocksPort[1]', '[::]:9050')
- aug.set(TOR_CONFIG + '/SocksPort[2]', '0.0.0.0:9050')
- aug.set(TOR_CONFIG + '/ControlPort', '9051')
+ aug.set(TOR_CONFIG_AUG + '/ControlPort', '9051')
_enable_relay(relay=True, bridge=True, aug=aug)
- aug.set(TOR_CONFIG + '/ExitPolicy[1]', 'reject *:*')
- aug.set(TOR_CONFIG + '/ExitPolicy[2]', 'reject6 *:*')
+ aug.set(TOR_CONFIG_AUG + '/ExitPolicy[1]', 'reject *:*')
+ aug.set(TOR_CONFIG_AUG + '/ExitPolicy[2]', 'reject6 *:*')
- aug.set(TOR_CONFIG + '/VirtualAddrNetworkIPv4', '10.192.0.0/10')
- aug.set(TOR_CONFIG + '/AutomapHostsOnResolve', '1')
- aug.set(TOR_CONFIG + '/TransPort[1]', '127.0.0.1:9040')
- aug.set(TOR_CONFIG + '/TransPort[2]', '[::1]:9040')
- aug.set(TOR_CONFIG + '/DNSPort[1]', '127.0.0.1:9053')
- aug.set(TOR_CONFIG + '/DNSPort[2]', '[::1]:9053')
-
- aug.set(TOR_CONFIG + '/HiddenServiceDir',
- '/var/lib/tor-instances/plinth/hidden_service')
- aug.set(TOR_CONFIG + '/HiddenServicePort[1]', '22 127.0.0.1:22')
- aug.set(TOR_CONFIG + '/HiddenServicePort[2]', '80 127.0.0.1:80')
- aug.set(TOR_CONFIG + '/HiddenServicePort[3]', '443 127.0.0.1:443')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServiceDir',
+ f'/var/lib/tor-instances/{INSTANCE_NAME}/hidden_service')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[1]', '22 127.0.0.1:22')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[2]', '80 127.0.0.1:80')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[3]', '443 127.0.0.1:443')
aug.save()
- action_utils.service_enable('tor@plinth')
- action_utils.service_restart('tor@plinth')
+ action_utils.service_enable(SERVICE_NAME)
+ action_utils.service_restart(SERVICE_NAME)
_update_ports()
# wait until hidden service information is available
@@ -110,28 +109,45 @@ def _upgrade_orport_value():
443 is not possible in FreedomBox due it is use for other purposes.
"""
+ logger.info('Upgrading ORPort value for Tor')
aug = augeas_load()
if _is_relay_enabled(aug):
- aug.set(TOR_CONFIG + '/ORPort[1]', '9001')
- aug.set(TOR_CONFIG + '/ORPort[2]', '[::]:9001')
+ aug.set(TOR_CONFIG_AUG + '/ORPort[1]', '9001')
+ aug.set(TOR_CONFIG_AUG + '/ORPort[2]', '[::]:9001')
aug.save()
- action_utils.service_try_restart('tor@plinth')
+ action_utils.service_try_restart(SERVICE_NAME)
# Tor may not be running, don't try to read/update all ports
_update_port('orport', 9001)
action_utils.service_restart('firewalld')
+def _remove_proxy():
+ """Remove SocksProxy from configuration.
+
+ This functionality was split off to a separate app, Tor Proxy.
+ """
+ logger.info('Removing SocksProxy from Tor configuration')
+ aug = augeas_load()
+ for config in [
+ 'SocksPort', 'VirtualAddrNetworkIPv4', 'AutomapHostsOnResolve',
+ 'TransPort', 'DNSPort'
+ ]:
+ aug.remove(TOR_CONFIG_AUG + '/' + config)
+
+ aug.save()
+ action_utils.service_try_restart(SERVICE_NAME)
+
+
@privileged
def configure(use_upstream_bridges: Optional[bool] = None,
upstream_bridges: Optional[str] = None,
relay: Optional[bool] = None,
bridge_relay: Optional[bool] = None,
- hidden_service: Optional[bool] = None,
- apt_transport_tor: Optional[bool] = None):
+ hidden_service: Optional[bool] = None):
"""Configure Tor."""
aug = augeas_load()
@@ -151,11 +167,6 @@ def configure(use_upstream_bridges: Optional[bool] = None,
elif hidden_service is not None:
_disable_hs(aug=aug)
- if apt_transport_tor:
- _enable_apt_transport_tor()
- elif apt_transport_tor is not None:
- _disable_apt_transport_tor()
-
@privileged
def update_ports():
@@ -166,12 +177,12 @@ def update_ports():
@privileged
def restart():
"""Restart Tor."""
- if (action_utils.service_is_enabled('tor@plinth', strict_check=True)
- and action_utils.service_is_running('tor@plinth')):
- action_utils.service_restart('tor@plinth')
+ if (action_utils.service_is_enabled(SERVICE_NAME, strict_check=True)
+ and action_utils.service_is_running(SERVICE_NAME)):
+ action_utils.service_restart(SERVICE_NAME)
aug = augeas_load()
- if aug.get(TOR_CONFIG + '/HiddenServiceDir'):
+ if aug.get(TOR_CONFIG_AUG + '/HiddenServiceDir'):
# wait until hidden service information is available
tries = 0
while not _get_hidden_service()['enabled']:
@@ -197,26 +208,26 @@ def get_status() -> dict[str, Union[bool, str, dict[str, Any]]]:
def _are_upstream_bridges_enabled(aug) -> bool:
"""Return whether upstream bridges are being used."""
- use_bridges = aug.get(TOR_CONFIG + '/UseBridges')
+ use_bridges = aug.get(TOR_CONFIG_AUG + '/UseBridges')
return use_bridges == '1'
def _get_upstream_bridges(aug) -> str:
"""Return upstream bridges separated by newlines."""
- matches = aug.match(TOR_CONFIG + '/Bridge')
+ matches = aug.match(TOR_CONFIG_AUG + '/Bridge')
bridges = [aug.get(match) for match in matches]
return '\n'.join(bridges)
def _is_relay_enabled(aug) -> bool:
"""Return whether a relay is enabled."""
- orport = aug.get(TOR_CONFIG + '/ORPort[1]')
+ orport = aug.get(TOR_CONFIG_AUG + '/ORPort[1]')
return bool(orport) and orport != '0'
def _is_bridge_relay_enabled(aug) -> bool:
"""Return whether bridge relay is enabled."""
- bridge = aug.get(TOR_CONFIG + '/BridgeRelay')
+ bridge = aug.get(TOR_CONFIG_AUG + '/BridgeRelay')
return bridge == '1'
@@ -272,8 +283,8 @@ def _get_hidden_service(aug=None) -> dict[str, Any]:
if not aug:
aug = augeas_load()
- hs_dir = aug.get(TOR_CONFIG + '/HiddenServiceDir')
- hs_port_paths = aug.match(TOR_CONFIG + '/HiddenServicePort')
+ hs_dir = aug.get(TOR_CONFIG_AUG + '/HiddenServiceDir')
+ hs_port_paths = aug.match(TOR_CONFIG_AUG + '/HiddenServicePort')
for hs_port_path in hs_port_paths:
port_info = aug.get(hs_port_path).split()
@@ -300,14 +311,13 @@ def _get_hidden_service(aug=None) -> dict[str, Any]:
def _enable():
"""Enable and start the service."""
- action_utils.service_enable('tor@plinth')
+ action_utils.service_enable(SERVICE_NAME)
_update_ports()
def _disable():
"""Disable and stop the service."""
- _disable_apt_transport_tor()
- action_utils.service_disable('tor@plinth')
+ action_utils.service_disable(SERVICE_NAME)
def _use_upstream_bridges(use_upstream_bridges: Optional[bool] = None,
@@ -320,9 +330,9 @@ def _use_upstream_bridges(use_upstream_bridges: Optional[bool] = None,
aug = augeas_load()
if use_upstream_bridges:
- aug.set(TOR_CONFIG + '/UseBridges', '1')
+ aug.set(TOR_CONFIG_AUG + '/UseBridges', '1')
else:
- aug.set(TOR_CONFIG + '/UseBridges', '0')
+ aug.set(TOR_CONFIG_AUG + '/UseBridges', '0')
aug.save()
@@ -335,16 +345,16 @@ def _set_upstream_bridges(upstream_bridges=None, aug=None):
if not aug:
aug = augeas_load()
- aug.remove(TOR_CONFIG + '/Bridge')
+ aug.remove(TOR_CONFIG_AUG + '/Bridge')
if upstream_bridges:
bridges = [bridge.strip() for bridge in upstream_bridges.split('\n')]
bridges = [bridge for bridge in bridges if bridge]
for bridge in bridges:
parts = [part for part in bridge.split() if part]
bridge = ' '.join(parts)
- aug.set(TOR_CONFIG + '/Bridge[last() + 1]', bridge.strip())
+ aug.set(TOR_CONFIG_AUG + '/Bridge[last() + 1]', bridge.strip())
- aug.set(TOR_CONFIG + '/ClientTransportPlugin',
+ aug.set(TOR_CONFIG_AUG + '/ClientTransportPlugin',
'obfs3,scramblesuit,obfs4 exec /usr/bin/obfs4proxy')
aug.save()
@@ -362,20 +372,20 @@ def _enable_relay(relay: Optional[bool], bridge: Optional[bool],
use_upstream_bridges = _are_upstream_bridges_enabled(aug)
if relay and not use_upstream_bridges:
- aug.set(TOR_CONFIG + '/ORPort[1]', '9001')
- aug.set(TOR_CONFIG + '/ORPort[2]', '[::]:9001')
+ aug.set(TOR_CONFIG_AUG + '/ORPort[1]', '9001')
+ aug.set(TOR_CONFIG_AUG + '/ORPort[2]', '[::]:9001')
elif relay is not None:
- aug.remove(TOR_CONFIG + '/ORPort')
+ aug.remove(TOR_CONFIG_AUG + '/ORPort')
if bridge and not use_upstream_bridges:
- aug.set(TOR_CONFIG + '/BridgeRelay', '1')
- aug.set(TOR_CONFIG + '/ServerTransportPlugin',
+ aug.set(TOR_CONFIG_AUG + '/BridgeRelay', '1')
+ aug.set(TOR_CONFIG_AUG + '/ServerTransportPlugin',
'obfs3,obfs4 exec /usr/bin/obfs4proxy')
- aug.set(TOR_CONFIG + '/ExtORPort', 'auto')
+ aug.set(TOR_CONFIG_AUG + '/ExtORPort', 'auto')
elif bridge is not None:
- aug.remove(TOR_CONFIG + '/BridgeRelay')
- aug.remove(TOR_CONFIG + '/ServerTransportPlugin')
- aug.remove(TOR_CONFIG + '/ExtORPort')
+ aug.remove(TOR_CONFIG_AUG + '/BridgeRelay')
+ aug.remove(TOR_CONFIG_AUG + '/ServerTransportPlugin')
+ aug.remove(TOR_CONFIG_AUG + '/ExtORPort')
aug.save()
@@ -388,11 +398,11 @@ def _enable_hs(aug=None):
if _get_hidden_service(aug)['enabled']:
return
- aug.set(TOR_CONFIG + '/HiddenServiceDir',
- '/var/lib/tor-instances/plinth/hidden_service')
- aug.set(TOR_CONFIG + '/HiddenServicePort[1]', '22 127.0.0.1:22')
- aug.set(TOR_CONFIG + '/HiddenServicePort[2]', '80 127.0.0.1:80')
- aug.set(TOR_CONFIG + '/HiddenServicePort[3]', '443 127.0.0.1:443')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServiceDir',
+ f'/var/lib/tor-instances/{INSTANCE_NAME}/hidden_service')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[1]', '22 127.0.0.1:22')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[2]', '80 127.0.0.1:80')
+ aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[3]', '443 127.0.0.1:443')
aug.save()
_set_onion_header(_get_hidden_service(aug))
@@ -405,39 +415,12 @@ def _disable_hs(aug=None):
if not _get_hidden_service(aug)['enabled']:
return
- aug.remove(TOR_CONFIG + '/HiddenServiceDir')
- aug.remove(TOR_CONFIG + '/HiddenServicePort')
+ aug.remove(TOR_CONFIG_AUG + '/HiddenServiceDir')
+ aug.remove(TOR_CONFIG_AUG + '/HiddenServicePort')
aug.save()
_set_onion_header(None)
-def _enable_apt_transport_tor():
- """Enable package download over Tor."""
- aug = get_augeas()
- for uri_path in iter_apt_uris(aug):
- uri = aug.get(uri_path)
- if uri.startswith('http://') or uri.startswith('https://'):
- aug.set(uri_path, APT_TOR_PREFIX + uri)
-
- aug.save()
-
-
-def _disable_apt_transport_tor():
- """Disable package download over Tor."""
- try:
- aug = get_augeas()
- except Exception:
- # Disable what we can, so APT is not unusable.
- pass
-
- for uri_path in iter_apt_uris(aug):
- uri = aug.get(uri_path)
- if uri.startswith(APT_TOR_PREFIX):
- aug.set(uri_path, uri[len(APT_TOR_PREFIX):])
-
- aug.save()
-
-
def _update_port(name, number):
"""Update firewall service information for single port."""
lines = """
@@ -485,14 +468,14 @@ def augeas_load():
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
aug.set('/augeas/load/Tor/lens', 'Tor.lns')
- aug.set('/augeas/load/Tor/incl[last() + 1]',
- '/etc/tor/instances/plinth/torrc')
+ aug.set('/augeas/load/Tor/incl[last() + 1]', TOR_CONFIG)
aug.load()
return aug
def _set_onion_header(hidden_service):
"""Set Apache configuration for the Onion-Location header."""
+ logger.info('Setting Onion-Location header for Apache')
config_file = pathlib.Path(TOR_APACHE_SITE)
if hidden_service and hidden_service['enabled']:
# https://community.torproject.org/onion-services/advanced/onion-location/
@@ -512,10 +495,13 @@ def _set_onion_header(hidden_service):
@privileged
def uninstall():
- """Remove create instances."""
+ """Remove plinth instance."""
directories = [
- '/etc/tor/instances/', '/var/lib/tor-instances/',
- '/var/run/tor-instances/'
+ f'/etc/tor/instances/{INSTANCE_NAME}/',
+ f'/var/lib/tor-instances/{INSTANCE_NAME}/',
+ f'/var/run/tor-instances/{INSTANCE_NAME}/'
]
for directory in directories:
shutil.rmtree(directory, ignore_errors=True)
+
+ os.unlink(f'/var/run/tor-instances/{INSTANCE_NAME}.defaults')
diff --git a/plinth/modules/tor/tests/test_functional.py b/plinth/modules/tor/tests/test_functional.py
index a1ce6bcef..8abab7a7b 100644
--- a/plinth/modules/tor/tests/test_functional.py
+++ b/plinth/modules/tor/tests/test_functional.py
@@ -11,7 +11,6 @@ _TOR_FEATURE_TO_ELEMENT = {
'relay': 'tor-relay_enabled',
'bridge-relay': 'tor-bridge_relay_enabled',
'hidden-services': 'tor-hs_enabled',
- 'software': 'tor-apt_transport_tor_enabled'
}
pytestmark = [pytest.mark.apps, pytest.mark.domain, pytest.mark.tor]
@@ -19,11 +18,8 @@ pytestmark = [pytest.mark.apps, pytest.mark.domain, pytest.mark.tor]
class TestTorApp(functional.BaseAppTests):
app_name = 'tor'
- has_service = True
+ has_service = False
has_web = False
- # TODO: Investigate why accessing IPv6 sites through Tor fails in
- # container.
- check_diagnostics = False
def test_set_tor_relay_configuration(self, session_browser):
"""Test setting Tor relay configuration."""
@@ -52,13 +48,6 @@ class TestTorApp(functional.BaseAppTests):
enabled=True)
_assert_hidden_services(session_browser)
- def test_set_download_software_packages_over_tor(self, session_browser):
- """Test setting download software packages over Tor."""
- functional.app_enable(session_browser, 'tor')
- _feature_enable(session_browser, 'software', should_enable=True)
- _feature_enable(session_browser, 'software', should_enable=False)
- _assert_feature_enabled(session_browser, 'software', enabled=False)
-
# TODO: Test more thoroughly by checking same hidden service is restored
# and by actually connecting using Tor.
@pytest.mark.backups
diff --git a/plinth/modules/tor/tests/test_tor.py b/plinth/modules/tor/tests/test_tor.py
index de0939a9a..3c61a57b6 100644
--- a/plinth/modules/tor/tests/test_tor.py
+++ b/plinth/modules/tor/tests/test_tor.py
@@ -14,14 +14,6 @@ from plinth.modules.tor import forms, utils
class TestTor:
"""Test cases for testing the Tor module."""
- @staticmethod
- @pytest.mark.usefixtures('needs_root')
- def test_is_apt_transport_tor_enabled():
- """Test that is_apt_transport_tor_enabled does not raise any unhandled
- exceptions.
- """
- utils.is_apt_transport_tor_enabled()
-
@staticmethod
@patch('plinth.app.App.get')
@pytest.mark.usefixtures('needs_root', 'load_cfg')
@@ -29,7 +21,7 @@ class TestTor:
"""Test that get_status does not raise any unhandled exceptions.
This should work regardless of whether tor is installed, or
- /etc/tor/torrc exists.
+ /etc/tor/instances/plinth/torrc exists.
"""
utils.get_status()
diff --git a/plinth/modules/tor/utils.py b/plinth/modules/tor/utils.py
index ff53b9c9a..0f16251e7 100644
--- a/plinth/modules/tor/utils.py
+++ b/plinth/modules/tor/utils.py
@@ -1,21 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tor utility functions."""
-import itertools
-
-import augeas
-
from plinth import app as app_module
from plinth.daemon import app_is_running
from plinth.modules.names.components import DomainName
from . import privileged
-APT_SOURCES_URI_PATHS = ('/files/etc/apt/sources.list/*/uri',
- '/files/etc/apt/sources.list.d/*/*/uri',
- '/files/etc/apt/sources.list.d/*/*/URIs/*')
-APT_TOR_PREFIX = 'tor+'
-
def get_status(initialized=True):
"""Return current Tor status."""
@@ -53,50 +44,4 @@ def get_status(initialized=True):
'hs_hostname': hs_info['hostname'],
'hs_ports': hs_info['ports'],
'hs_services': hs_services,
- 'apt_transport_tor_enabled': is_apt_transport_tor_enabled()
}
-
-
-def iter_apt_uris(aug):
- """Iterate over all the APT source URIs."""
- return itertools.chain.from_iterable(
- [aug.match(path) for path in APT_SOURCES_URI_PATHS])
-
-
-def get_augeas():
- """Return an instance of Augeaus for processing APT configuration."""
- aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
- augeas.Augeas.NO_MODL_AUTOLOAD)
- aug.set('/augeas/load/Aptsources/lens', 'Aptsources.lns')
- aug.set('/augeas/load/Aptsources/incl[last() + 1]',
- '/etc/apt/sources.list')
- aug.set('/augeas/load/Aptsources/incl[last() + 1]',
- '/etc/apt/sources.list.d/*.list')
- aug.set('/augeas/load/Aptsources822/lens', 'Aptsources822.lns')
- aug.set('/augeas/load/Aptsources822/incl[last() + 1]',
- '/etc/apt/sources.list.d/*.sources')
- aug.load()
-
- # Check for any errors in parsing sources lists.
- if aug.match('/augeas/files/etc/apt/sources.list/error') or \
- aug.match('/augeas/files/etc/apt/sources.list.d//error'):
- raise Exception('Error parsing sources list')
-
- return aug
-
-
-def is_apt_transport_tor_enabled():
- """Return whether APT is set to download packages over Tor."""
- try:
- aug = get_augeas()
- except Exception:
- # If there was an error with parsing.
- return False
-
- for uri_path in iter_apt_uris(aug):
- uri = aug.get(uri_path)
- if not uri.startswith(APT_TOR_PREFIX) and \
- (uri.startswith('http://') or uri.startswith('https://')):
- return False
-
- return True
diff --git a/plinth/modules/tor/views.py b/plinth/modules/tor/views.py
index 9d7c3d4ab..3d348bb78 100644
--- a/plinth/modules/tor/views.py
+++ b/plinth/modules/tor/views.py
@@ -98,11 +98,6 @@ def __apply_changes(old_status, new_status):
arguments['hidden_service'] = new_status['hs_enabled']
needs_restart = True
- if old_status['apt_transport_tor_enabled'] != \
- new_status['apt_transport_tor_enabled']:
- arguments['apt_transport_tor'] = (
- is_enabled and new_status['apt_transport_tor_enabled'])
-
if old_status['use_upstream_bridges'] != \
new_status['use_upstream_bridges']:
arguments['use_upstream_bridges'] = new_status['use_upstream_bridges']
diff --git a/plinth/modules/torproxy/__init__.py b/plinth/modules/torproxy/__init__.py
new file mode 100644
index 000000000..41580bc2a
--- /dev/null
+++ b/plinth/modules/torproxy/__init__.py
@@ -0,0 +1,130 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""FreedomBox app to configure Tor Proxy."""
+
+import logging
+
+from django.utils.translation import gettext_lazy as _
+
+from plinth import app as app_module
+from plinth import cfg, menu
+from plinth.daemon import Daemon
+from plinth.modules.apache.components import diagnose_url
+from plinth.modules.backups.components import BackupRestore
+from plinth.modules.firewall.components import Firewall
+from plinth.modules.users.components import UsersAndGroups
+from plinth.package import Packages
+from plinth.utils import format_lazy
+
+from . import manifest, privileged
+
+logger = logging.getLogger(__name__)
+
+_description = [
+ _('Tor is an anonymous communication system. You can learn more '
+ 'about it from the Tor '
+ 'Project website. For best protection when web surfing, the '
+ 'Tor Project recommends that you use the '
+ ''
+ 'Tor Browser.'),
+ format_lazy(
+ _('A Tor SOCKS port is available on your {box_name} for internal '
+ 'networks on TCP port 9050.'), box_name=_(cfg.box_name))
+]
+
+
+class TorProxyApp(app_module.App):
+ """FreedomBox app for Tor Proxy."""
+
+ app_id = 'torproxy'
+
+ _version = 1
+
+ def __init__(self):
+ """Create components for the app."""
+ super().__init__()
+
+ info = app_module.Info(app_id=self.app_id, version=self._version,
+ name=_('Tor Proxy'), icon_filename='tor',
+ short_description=_('Anonymity Network'),
+ description=_description,
+ manual_page='TorProxy',
+ clients=manifest.clients,
+ donation_url='https://donate.torproject.org/')
+ self.add(info)
+
+ menu_item = menu.Menu('menu-torproxy', info.name,
+ info.short_description, info.icon_filename,
+ 'torproxy:index', parent_url_name='apps')
+ self.add(menu_item)
+
+ packages = Packages('packages-torproxy', [
+ 'tor', 'tor-geoipdb', 'torsocks', 'obfs4proxy', 'apt-transport-tor'
+ ])
+ self.add(packages)
+
+ firewall = Firewall('firewall-torproxy-socks', _('Tor Socks Proxy'),
+ ports=['tor-socks'], is_external=False)
+ self.add(firewall)
+
+ daemon = Daemon(
+ 'daemon-torproxy', 'tor@fbxproxy', strict_check=True,
+ listen_ports=[(9050, 'tcp4'), (9050, 'tcp6'), (9040, 'tcp4'),
+ (9040, 'tcp6'), (9053, 'udp4'), (9053, 'udp6')])
+ self.add(daemon)
+
+ users_and_groups = UsersAndGroups('users-and-groups-torproxy',
+ reserved_usernames=['debian-tor'])
+ self.add(users_and_groups)
+
+ backup_restore = BackupRestore('backup-restore-torproxy',
+ **manifest.backup)
+ self.add(backup_restore)
+
+ def disable(self):
+ """Disable APT use of Tor before disabling."""
+ privileged.configure(apt_transport_tor=False)
+ super().disable()
+
+ def diagnose(self):
+ """Run diagnostics and return the results."""
+ results = super().diagnose()
+ results.append(_diagnose_url_via_tor('http://www.debian.org', '4'))
+ results.append(_diagnose_url_via_tor('http://www.debian.org', '6'))
+ results.append(_diagnose_tor_use('https://check.torproject.org', '4'))
+ results.append(_diagnose_tor_use('https://check.torproject.org', '6'))
+ return results
+
+ def setup(self, old_version):
+ """Install and configure the app."""
+ super().setup(old_version)
+ privileged.setup(old_version)
+ if not old_version:
+ logger.info('Enabling apt-transport-tor')
+ privileged.configure(apt_transport_tor=True)
+ logger.info('Enabling Tor Proxy app')
+ self.enable()
+
+ def uninstall(self):
+ """De-configure and uninstall the app."""
+ super().uninstall()
+ privileged.uninstall()
+
+
+def _diagnose_url_via_tor(url, kind=None):
+ """Diagnose whether a URL is reachable via Tor."""
+ result = diagnose_url(url, kind=kind, wrapper='torsocks')
+ result[0] = _('Access URL {url} on tcp{kind} via Tor') \
+ .format(url=url, kind=kind)
+
+ return result
+
+
+def _diagnose_tor_use(url, kind=None):
+ """Diagnose whether webpage at URL reports that we are using Tor."""
+ expected_output = 'Congratulations. This browser is configured to use Tor.'
+ result = diagnose_url(url, kind=kind, wrapper='torsocks',
+ expected_output=expected_output)
+ result[0] = _('Confirm Tor usage at {url} on tcp{kind}') \
+ .format(url=url, kind=kind)
+
+ return result
diff --git a/plinth/modules/torproxy/data/usr/share/freedombox/modules-enabled/torproxy b/plinth/modules/torproxy/data/usr/share/freedombox/modules-enabled/torproxy
new file mode 100644
index 000000000..08a91a7d9
--- /dev/null
+++ b/plinth/modules/torproxy/data/usr/share/freedombox/modules-enabled/torproxy
@@ -0,0 +1 @@
+plinth.modules.torproxy
diff --git a/plinth/modules/torproxy/forms.py b/plinth/modules/torproxy/forms.py
new file mode 100644
index 000000000..1f70e212f
--- /dev/null
+++ b/plinth/modules/torproxy/forms.py
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Forms for configuring Tor Proxy.
+"""
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from plinth.modules.tor.forms import TorCommonForm
+
+
+class TorProxyForm(TorCommonForm):
+ """Tor Proxy configuration form."""
+ apt_transport_tor_enabled = forms.BooleanField(
+ label=_('Download software packages over Tor'), required=False,
+ help_text=_('When enabled, software will be downloaded over the Tor '
+ 'network for installations and upgrades. This adds a '
+ 'degree of privacy and security during software '
+ 'downloads.'))
diff --git a/plinth/modules/torproxy/manifest.py b/plinth/modules/torproxy/manifest.py
new file mode 100644
index 000000000..a5a2fb777
--- /dev/null
+++ b/plinth/modules/torproxy/manifest.py
@@ -0,0 +1,52 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""App manifest for Tor Proxy."""
+
+from django.utils.translation import gettext_lazy as _
+
+from plinth.clients import store_url
+
+_ORBOT_PACKAGE_ID = 'org.torproject.android'
+_TOR_BROWSER_DOWNLOAD_URL = \
+ 'https://www.torproject.org/download/download-easy.html'
+
+clients = [{
+ 'name':
+ _('Tor Browser'),
+ 'platforms': [{
+ 'type': 'download',
+ 'os': 'windows',
+ 'url': _TOR_BROWSER_DOWNLOAD_URL,
+ }, {
+ 'type': 'download',
+ 'os': 'gnu-linux',
+ 'url': _TOR_BROWSER_DOWNLOAD_URL,
+ }, {
+ 'type': 'download',
+ 'os': 'macos',
+ 'url': _TOR_BROWSER_DOWNLOAD_URL,
+ }]
+}, {
+ 'name':
+ _('Orbot: Proxy with Tor'),
+ 'platforms': [{
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'google-play',
+ 'url': store_url('google-play', _ORBOT_PACKAGE_ID)
+ }, {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'f-droid',
+ 'url': store_url('f-droid', _ORBOT_PACKAGE_ID)
+ }]
+}]
+
+backup = {
+ 'config': {
+ 'directories': ['/etc/tor/instances/fbxproxy/'],
+ },
+ 'secrets': {
+ 'directories': ['/var/lib/tor-instances/fbxproxy/']
+ },
+ 'services': ['tor@fbxproxy']
+}
diff --git a/plinth/modules/torproxy/privileged.py b/plinth/modules/torproxy/privileged.py
new file mode 100644
index 000000000..5a78c7325
--- /dev/null
+++ b/plinth/modules/torproxy/privileged.py
@@ -0,0 +1,219 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Configure Tor Proxy service."""
+
+import logging
+import os
+import shutil
+import subprocess
+from typing import Any, Optional, Union
+
+import augeas
+
+from plinth import action_utils
+from plinth.actions import privileged
+from plinth.modules.torproxy.utils import (APT_TOR_PREFIX, get_augeas,
+ iter_apt_uris)
+
+logger = logging.getLogger(__name__)
+
+INSTANCE_NAME = 'fbxproxy'
+SERVICE_FILE = '/etc/firewalld/services/tor-{0}.xml'
+SERVICE_NAME = f'tor@{INSTANCE_NAME}'
+TORPROXY_CONFIG = f'/etc/tor/instances/{INSTANCE_NAME}/torrc'
+TORPROXY_CONFIG_AUG = f'/files/{TORPROXY_CONFIG}'
+
+
+@privileged
+def setup(old_version: int):
+ """Setup Tor configuration after installing it."""
+ _first_time_setup()
+
+
+def _first_time_setup():
+ """Setup Tor configuration for the first time setting defaults."""
+ logger.info('Performing first time setup for Tor Proxy')
+ # Disable default tor service. We will use tor@fbxproxy instance
+ # instead.
+ _disable_apt_transport_tor()
+ action_utils.service_disable('tor@default')
+
+ subprocess.run(['tor-instance-create', INSTANCE_NAME], check=True)
+
+ # Remove line starting with +SocksPort, since our augeas lens
+ # doesn't handle it correctly.
+ with open(TORPROXY_CONFIG, 'r', encoding='utf-8') as torrc:
+ torrc_lines = torrc.readlines()
+ with open(TORPROXY_CONFIG, 'w', encoding='utf-8') as torrc:
+ for line in torrc_lines:
+ if not line.startswith('+'):
+ torrc.write(line)
+
+ aug = augeas_load()
+
+ aug.set(TORPROXY_CONFIG_AUG + '/SocksPort[1]', '[::]:9050')
+ aug.set(TORPROXY_CONFIG_AUG + '/SocksPort[2]', '0.0.0.0:9050')
+ aug.set(TORPROXY_CONFIG_AUG + '/ExitPolicy[1]', 'reject *:*')
+ aug.set(TORPROXY_CONFIG_AUG + '/ExitPolicy[2]', 'reject6 *:*')
+ aug.set(TORPROXY_CONFIG_AUG + '/VirtualAddrNetworkIPv4', '10.192.0.0/10')
+ aug.set(TORPROXY_CONFIG_AUG + '/AutomapHostsOnResolve', '1')
+ aug.set(TORPROXY_CONFIG_AUG + '/TransPort[1]', '127.0.0.1:9040')
+ aug.set(TORPROXY_CONFIG_AUG + '/TransPort[2]', '[::1]:9040')
+ aug.set(TORPROXY_CONFIG_AUG + '/DNSPort[1]', '127.0.0.1:9053')
+ aug.set(TORPROXY_CONFIG_AUG + '/DNSPort[2]', '[::1]:9053')
+
+ aug.save()
+
+ action_utils.service_enable(SERVICE_NAME)
+ action_utils.service_restart(SERVICE_NAME)
+
+
+@privileged
+def configure(use_upstream_bridges: Optional[bool] = None,
+ upstream_bridges: Optional[str] = None,
+ apt_transport_tor: Optional[bool] = None):
+ """Configure Tor."""
+ aug = augeas_load()
+
+ _use_upstream_bridges(use_upstream_bridges, aug=aug)
+
+ if upstream_bridges:
+ _set_upstream_bridges(upstream_bridges, aug=aug)
+
+ if apt_transport_tor:
+ _enable_apt_transport_tor()
+ elif apt_transport_tor is not None:
+ _disable_apt_transport_tor()
+
+
+@privileged
+def restart():
+ """Restart Tor."""
+ if (action_utils.service_is_enabled(SERVICE_NAME, strict_check=True)
+ and action_utils.service_is_running(SERVICE_NAME)):
+ action_utils.service_restart(SERVICE_NAME)
+
+
+@privileged
+def get_status() -> dict[str, Union[bool, str, dict[str, Any]]]:
+ """Return dict with Tor Proxy status."""
+ aug = augeas_load()
+ return {
+ 'use_upstream_bridges': _are_upstream_bridges_enabled(aug),
+ 'upstream_bridges': _get_upstream_bridges(aug)
+ }
+
+
+def _are_upstream_bridges_enabled(aug) -> bool:
+ """Return whether upstream bridges are being used."""
+ use_bridges = aug.get(TORPROXY_CONFIG_AUG + '/UseBridges')
+ return use_bridges == '1'
+
+
+def _get_upstream_bridges(aug) -> str:
+ """Return upstream bridges separated by newlines."""
+ matches = aug.match(TORPROXY_CONFIG_AUG + '/Bridge')
+ bridges = [aug.get(match) for match in matches]
+ return '\n'.join(bridges)
+
+
+def _enable():
+ """Enable and start the service."""
+ action_utils.service_enable(SERVICE_NAME)
+
+
+def _disable():
+ """Disable and stop the service."""
+ _disable_apt_transport_tor()
+ action_utils.service_disable(SERVICE_NAME)
+
+
+def _use_upstream_bridges(use_upstream_bridges: Optional[bool] = None,
+ aug=None):
+ """Enable use of upstream bridges."""
+ if use_upstream_bridges is None:
+ return
+
+ if not aug:
+ aug = augeas_load()
+
+ if use_upstream_bridges:
+ aug.set(TORPROXY_CONFIG_AUG + '/UseBridges', '1')
+ else:
+ aug.set(TORPROXY_CONFIG_AUG + '/UseBridges', '0')
+
+ aug.save()
+
+
+def _set_upstream_bridges(upstream_bridges=None, aug=None):
+ """Set list of upstream bridges."""
+ if upstream_bridges is None:
+ return
+
+ if not aug:
+ aug = augeas_load()
+
+ aug.remove(TORPROXY_CONFIG_AUG + '/Bridge')
+ if upstream_bridges:
+ bridges = [bridge.strip() for bridge in upstream_bridges.split('\n')]
+ bridges = [bridge for bridge in bridges if bridge]
+ for bridge in bridges:
+ parts = [part for part in bridge.split() if part]
+ bridge = ' '.join(parts)
+ aug.set(TORPROXY_CONFIG_AUG + '/Bridge[last() + 1]',
+ bridge.strip())
+
+ aug.set(TORPROXY_CONFIG_AUG + '/ClientTransportPlugin',
+ 'obfs3,scramblesuit,obfs4 exec /usr/bin/obfs4proxy')
+
+ aug.save()
+
+
+def _enable_apt_transport_tor():
+ """Enable package download over Tor."""
+ aug = get_augeas()
+ for uri_path in iter_apt_uris(aug):
+ uri = aug.get(uri_path)
+ if uri.startswith('http://') or uri.startswith('https://'):
+ aug.set(uri_path, APT_TOR_PREFIX + uri)
+
+ aug.save()
+
+
+def _disable_apt_transport_tor():
+ """Disable package download over Tor."""
+ try:
+ aug = get_augeas()
+ except Exception:
+ # Disable what we can, so APT is not unusable.
+ pass
+
+ for uri_path in iter_apt_uris(aug):
+ uri = aug.get(uri_path)
+ if uri.startswith(APT_TOR_PREFIX):
+ aug.set(uri_path, uri[len(APT_TOR_PREFIX):])
+
+ aug.save()
+
+
+def augeas_load():
+ """Initialize Augeas."""
+ aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
+ augeas.Augeas.NO_MODL_AUTOLOAD)
+ aug.set('/augeas/load/Tor/lens', 'Tor.lns')
+ aug.set('/augeas/load/Tor/incl[last() + 1]', TORPROXY_CONFIG)
+ aug.load()
+ return aug
+
+
+@privileged
+def uninstall():
+ """Remove fbxproxy instance."""
+ directories = [
+ f'/etc/tor/instances/{INSTANCE_NAME}/',
+ f'/var/lib/tor-instances/{INSTANCE_NAME}/',
+ f'/var/run/tor-instances/{INSTANCE_NAME}/'
+ ]
+ for directory in directories:
+ shutil.rmtree(directory, ignore_errors=True)
+
+ os.unlink(f'/var/run/tor-instances/{INSTANCE_NAME}.defaults')
diff --git a/plinth/modules/torproxy/static/icons/tor.png b/plinth/modules/torproxy/static/icons/tor.png
new file mode 100644
index 000000000..e23f23312
Binary files /dev/null and b/plinth/modules/torproxy/static/icons/tor.png differ
diff --git a/plinth/modules/torproxy/static/icons/tor.svg b/plinth/modules/torproxy/static/icons/tor.svg
new file mode 100644
index 000000000..f64723078
--- /dev/null
+++ b/plinth/modules/torproxy/static/icons/tor.svg
@@ -0,0 +1,112 @@
+
+
diff --git a/plinth/modules/torproxy/static/torproxy.js b/plinth/modules/torproxy/static/torproxy.js
new file mode 100644
index 000000000..3469c5a88
--- /dev/null
+++ b/plinth/modules/torproxy/static/torproxy.js
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+/**
+ * @licstart The following is the entire license notice for the JavaScript
+ * code in this page.
+ *
+ * This file is part of FreedomBox.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * @licend The above is the entire license notice for the JavaScript code
+ * in this page.
+ */
+
+(function($) {
+ $('#id_torproxy-use_upstream_bridges').change(function() {
+ if ($('#id_torproxy-use_upstream_bridges').prop('checked')) {
+ $('#id_torproxy-upstream_bridges').parent().parent().show('slow');
+ } else {
+ $('#id_torproxy-upstream_bridges').parent().parent().hide('slow');
+ }
+ }).change();
+})(jQuery);
diff --git a/plinth/modules/torproxy/templates/torproxy.html b/plinth/modules/torproxy/templates/torproxy.html
new file mode 100644
index 000000000..6f0eda9e5
--- /dev/null
+++ b/plinth/modules/torproxy/templates/torproxy.html
@@ -0,0 +1,12 @@
+{% extends "app.html" %}
+{% comment %}
+# SPDX-License-Identifier: AGPL-3.0-or-later
+{% endcomment %}
+
+{% load bootstrap %}
+{% load i18n %}
+{% load static %}
+
+{% block page_js %}
+
+{% endblock %}
diff --git a/plinth/modules/torproxy/tests/__init__.py b/plinth/modules/torproxy/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plinth/modules/torproxy/tests/test_functional.py b/plinth/modules/torproxy/tests/test_functional.py
new file mode 100644
index 000000000..12e91af65
--- /dev/null
+++ b/plinth/modules/torproxy/tests/test_functional.py
@@ -0,0 +1,64 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Functional, browser based tests for Tor Proxy app.
+"""
+
+import pytest
+
+from plinth.tests import functional
+
+_TOR_FEATURE_TO_ELEMENT = {'software': 'torproxy-apt_transport_tor_enabled'}
+
+pytestmark = [pytest.mark.apps, pytest.mark.torproxy]
+
+
+class TestTorProxyApp(functional.BaseAppTests):
+ """Tests for the Tor Proxy app."""
+ app_name = 'torproxy'
+ has_service = True
+ has_web = False
+ # TODO: Investigate why accessing IPv6 sites through Tor fails in
+ # container.
+ check_diagnostics = False
+
+ def test_set_download_software_packages_over_tor(self, session_browser):
+ """Test setting download software packages over Tor."""
+ functional.app_enable(session_browser, 'torproxy')
+ _feature_enable(session_browser, 'software', should_enable=True)
+ _feature_enable(session_browser, 'software', should_enable=False)
+ _assert_feature_enabled(session_browser, 'software', enabled=False)
+
+ @pytest.mark.backups
+ def test_backup_restore(self, session_browser):
+ """Test backup and restore of configuration."""
+ functional.app_enable(session_browser, 'torproxy')
+ # TODO: Check that upstream bridges are restored.
+ functional.backup_create(session_browser, 'torproxy', 'test_torproxy')
+
+ functional.backup_restore(session_browser, 'torproxy', 'test_torproxy')
+
+ assert functional.service_is_running(session_browser, 'torproxy')
+
+
+def _feature_enable(browser, feature, should_enable):
+ """Enable/disable a Tor Proxy feature."""
+ element_name = _TOR_FEATURE_TO_ELEMENT[feature]
+ functional.nav_to_module(browser, 'torproxy')
+ checkbox_element = browser.find_by_name(element_name).first
+ if should_enable == checkbox_element.checked:
+ return
+
+ if should_enable:
+ checkbox_element.check()
+ else:
+ checkbox_element.uncheck()
+
+ functional.submit(browser, form_class='form-configuration')
+ functional.wait_for_config_update(browser, 'torproxy')
+
+
+def _assert_feature_enabled(browser, feature, enabled):
+ """Assert whether Tor Proxy feature is enabled or disabled."""
+ element_name = _TOR_FEATURE_TO_ELEMENT[feature]
+ functional.nav_to_module(browser, 'torproxy')
+ assert browser.find_by_name(element_name).first.checked == enabled
diff --git a/plinth/modules/torproxy/tests/test_torproxy.py b/plinth/modules/torproxy/tests/test_torproxy.py
new file mode 100644
index 000000000..8bd2970d5
--- /dev/null
+++ b/plinth/modules/torproxy/tests/test_torproxy.py
@@ -0,0 +1,33 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Tests for Tor Proxy module.
+"""
+
+from unittest.mock import patch
+
+import pytest
+
+from plinth.modules.torproxy import utils
+
+
+class TestTorProxy:
+ """Test cases for testing the Tor Proxy module."""
+
+ @staticmethod
+ @pytest.mark.usefixtures('needs_root')
+ def test_is_apt_transport_tor_enabled():
+ """Test that is_apt_transport_tor_enabled does not raise any unhandled
+ exceptions.
+ """
+ utils.is_apt_transport_tor_enabled()
+
+ @staticmethod
+ @patch('plinth.app.App.get')
+ @pytest.mark.usefixtures('needs_root', 'load_cfg')
+ def test_get_status(_app_get):
+ """Test that get_status does not raise any unhandled exceptions.
+
+ This should work regardless of whether tor is installed, or
+ /etc/tor/instances/fbxproxy/torrc exists.
+ """
+ utils.get_status()
diff --git a/plinth/modules/torproxy/urls.py b/plinth/modules/torproxy/urls.py
new file mode 100644
index 000000000..c894ae769
--- /dev/null
+++ b/plinth/modules/torproxy/urls.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+URLs for the Tor Proxy module.
+"""
+
+from django.urls import re_path
+
+from . import views
+
+urlpatterns = [
+ re_path(r'^apps/torproxy/$', views.TorProxyAppView.as_view(),
+ name='index'),
+]
diff --git a/plinth/modules/torproxy/utils.py b/plinth/modules/torproxy/utils.py
new file mode 100644
index 000000000..82f1e810e
--- /dev/null
+++ b/plinth/modules/torproxy/utils.py
@@ -0,0 +1,75 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Tor Proxy utility functions."""
+
+import itertools
+
+import augeas
+
+from plinth import app as app_module
+from plinth.daemon import app_is_running
+
+from . import privileged
+
+APT_SOURCES_URI_PATHS = ('/files/etc/apt/sources.list/*/uri',
+ '/files/etc/apt/sources.list.d/*/*/uri',
+ '/files/etc/apt/sources.list.d/*/*/URIs/*')
+APT_TOR_PREFIX = 'tor+'
+
+
+def get_status(initialized=True):
+ """Return current Tor status."""
+ status = privileged.get_status()
+
+ app = app_module.App.get('torproxy')
+ return {
+ 'enabled': app.is_enabled() if initialized else False,
+ 'is_running': app_is_running(app) if initialized else False,
+ 'use_upstream_bridges': status['use_upstream_bridges'],
+ 'upstream_bridges': status['upstream_bridges'],
+ 'apt_transport_tor_enabled': is_apt_transport_tor_enabled()
+ }
+
+
+def iter_apt_uris(aug):
+ """Iterate over all the APT source URIs."""
+ return itertools.chain.from_iterable(
+ [aug.match(path) for path in APT_SOURCES_URI_PATHS])
+
+
+def get_augeas():
+ """Return an instance of Augeaus for processing APT configuration."""
+ aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
+ augeas.Augeas.NO_MODL_AUTOLOAD)
+ aug.set('/augeas/load/Aptsources/lens', 'Aptsources.lns')
+ aug.set('/augeas/load/Aptsources/incl[last() + 1]',
+ '/etc/apt/sources.list')
+ aug.set('/augeas/load/Aptsources/incl[last() + 1]',
+ '/etc/apt/sources.list.d/*.list')
+ aug.set('/augeas/load/Aptsources822/lens', 'Aptsources822.lns')
+ aug.set('/augeas/load/Aptsources822/incl[last() + 1]',
+ '/etc/apt/sources.list.d/*.sources')
+ aug.load()
+
+ # Check for any errors in parsing sources lists.
+ if aug.match('/augeas/files/etc/apt/sources.list/error') or \
+ aug.match('/augeas/files/etc/apt/sources.list.d//error'):
+ raise Exception('Error parsing sources list')
+
+ return aug
+
+
+def is_apt_transport_tor_enabled():
+ """Return whether APT is set to download packages over Tor."""
+ try:
+ aug = get_augeas()
+ except Exception:
+ # If there was an error with parsing.
+ return False
+
+ for uri_path in iter_apt_uris(aug):
+ uri = aug.get(uri_path)
+ if not uri.startswith(APT_TOR_PREFIX) and \
+ (uri.startswith('http://') or uri.startswith('https://')):
+ return False
+
+ return True
diff --git a/plinth/modules/torproxy/views.py b/plinth/modules/torproxy/views.py
new file mode 100644
index 000000000..b4de80d9e
--- /dev/null
+++ b/plinth/modules/torproxy/views.py
@@ -0,0 +1,104 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""FreedomBox app for configuring Tor Proxy."""
+
+import logging
+
+from django.utils.translation import gettext_noop
+from django.views.generic.edit import FormView
+
+from plinth import app as app_module
+from plinth import operation as operation_module
+from plinth.views import AppView
+
+from . import privileged
+from . import utils as tor_utils
+from .forms import TorProxyForm
+
+logger = logging.getLogger(__name__)
+
+
+class TorProxyAppView(AppView):
+ """Show Tor Proxy app main page."""
+
+ app_id = 'torproxy'
+ template_name = 'torproxy.html'
+ form_class = TorProxyForm
+ prefix = 'torproxy'
+
+ status = None
+
+ def get_initial(self):
+ """Return the values to fill in the form."""
+ if not self.status:
+ self.status = tor_utils.get_status()
+
+ initial = super().get_initial()
+ initial.update(self.status)
+ return initial
+
+ def get_context_data(self, *args, **kwargs):
+ """Add additional context data for template."""
+ if not self.status:
+ self.status = tor_utils.get_status()
+
+ context = super().get_context_data(*args, **kwargs)
+ context['status'] = self.status
+ return context
+
+ def form_valid(self, form):
+ """Configure tor app on successful form submission."""
+ operation_module.manager.new(self.app_id,
+ gettext_noop('Updating configuration'),
+ _apply_changes,
+ [form.initial, form.cleaned_data],
+ show_notification=False)
+ # Skip check for 'Settings unchanged' message by calling grandparent
+ return super(FormView, self).form_valid(form)
+
+
+def _apply_changes(old_status, new_status):
+ """Try to apply changes and handle errors."""
+ logger.info('torproxy: applying configuration changes')
+ exception_to_update = None
+ message = None
+ try:
+ __apply_changes(old_status, new_status)
+ except Exception as exception:
+ exception_to_update = exception
+ message = gettext_noop('Error configuring app: {error}').format(
+ error=exception)
+ else:
+ message = gettext_noop('Configuration updated.')
+
+ logger.info('torproxy: configuration changes completed')
+ operation = operation_module.Operation.get_operation()
+ operation.on_update(message, exception_to_update)
+
+
+def __apply_changes(old_status, new_status):
+ """Apply the changes."""
+ needs_restart = False
+ arguments = {}
+
+ app = app_module.App.get('torproxy')
+ is_enabled = app.is_enabled()
+
+ if old_status['apt_transport_tor_enabled'] != \
+ new_status['apt_transport_tor_enabled']:
+ arguments['apt_transport_tor'] = (
+ is_enabled and new_status['apt_transport_tor_enabled'])
+
+ if old_status['use_upstream_bridges'] != \
+ new_status['use_upstream_bridges']:
+ arguments['use_upstream_bridges'] = new_status['use_upstream_bridges']
+ needs_restart = True
+
+ if old_status['upstream_bridges'] != new_status['upstream_bridges']:
+ arguments['upstream_bridges'] = new_status['upstream_bridges']
+ needs_restart = True
+
+ if arguments:
+ privileged.configure(**arguments)
+
+ if needs_restart and is_enabled:
+ privileged.restart()