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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + 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()