torproxy: Add separate app for Tor Proxy

- Includes SocksPort and "Download software packages over Tor" feature,
  as well as setting upstream bridges.

- "Download software packages over Tor" option is enabled by default.

- When upgrading, if Tor app was enabled and "Download software
  packages over Tor" was enabled, then Tor Proxy will be installed.

- The default tor instance is now called tor@default. The "tor" service
  is an multi-instance master that has Wants relation all instances.

Tests:

- Tests for Tor and Tor Proxy passed.

- Enable Tor, and run the tests for Tor Proxy. Afterwards, Tor is still
  enabled and running.

- Enable Tor Proxy, and run the tests for Tor. Afterwards, Tor Proxy is
  still enabled and running.

- Test setting upstream bridges for Tor and Tor Proxy.

- Install FreedomBox 23.11 in a VM and install Tor with default
  settings. Install new FreedomBox version with Tor Proxy. After
  install, both Tor and Tor Proxy apps are installed and running.
  /etc/tor/instances/{plinth,fbxproxy}/torrc both have expected content.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
James Valleroy 2023-06-09 15:37:21 -04:00 committed by Sunil Mohan Adapa
parent 64d6356c2f
commit b0c75b7849
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
23 changed files with 999 additions and 248 deletions

View File

@ -1,24 +1,29 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""FreedomBox app to configure Tor.""" """FreedomBox app to configure Tor."""
import logging
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plinth import action_utils from plinth import action_utils
from plinth import app as app_module 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, from plinth.daemon import (Daemon, app_is_running, diagnose_netcat,
diagnose_port_listening) 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.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall from plinth.modules.firewall.components import Firewall
from plinth.modules.names.components import DomainType 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.modules.users.components import UsersAndGroups
from plinth.package import Packages from plinth.package import Packages
from plinth.signals import domain_added, domain_removed from plinth.signals import domain_added, domain_removed
from plinth.utils import format_lazy
from . import manifest, privileged, utils from . import manifest, privileged, utils
logger = logging.getLogger(__name__)
_description = [ _description = [
_('Tor is an anonymous communication system. You can learn more ' _('Tor is an anonymous communication system. You can learn more '
'about it from the <a href="https://www.torproject.org/">Tor ' 'about it from the <a href="https://www.torproject.org/">Tor '
@ -26,9 +31,6 @@ _description = [
'Tor Project recommends that you use the ' 'Tor Project recommends that you use the '
'<a href="https://www.torproject.org/download/download-easy.html.en">' '<a href="https://www.torproject.org/download/download-easy.html.en">'
'Tor Browser</a>.'), 'Tor Browser</a>.'),
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' app_id = 'tor'
_version = 6 _version = 7
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
@ -57,28 +59,20 @@ class TorApp(app_module.App):
parent_url_name='apps') parent_url_name='apps')
self.add(menu_item) self.add(menu_item)
packages = Packages('packages-tor', [ packages = Packages('packages-tor',
'tor', 'tor-geoipdb', 'torsocks', 'obfs4proxy', 'apt-transport-tor' ['tor', 'tor-geoipdb', 'obfs4proxy'])
])
self.add(packages) self.add(packages)
domain_type = DomainType('domain-type-tor', _('Tor Onion Service'), domain_type = DomainType('domain-type-tor', _('Tor Onion Service'),
'tor:index', can_have_certificate=False) 'tor:index', can_have_certificate=False)
self.add(domain_type) 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'), firewall = Firewall('firewall-tor-relay', _('Tor Bridge Relay'),
ports=['tor-orport', 'tor-obfs3', ports=['tor-orport', 'tor-obfs3',
'tor-obfs4'], is_external=True) 'tor-obfs4'], is_external=True)
self.add(firewall) self.add(firewall)
daemon = Daemon( daemon = Daemon('daemon-tor', 'tor@plinth', strict_check=True)
'daemon-tor', 'tor@plinth', strict_check=True,
listen_ports=[(9050, 'tcp4'), (9050, 'tcp6'), (9040, 'tcp4'),
(9040, 'tcp6'), (9053, 'udp4'), (9053, 'udp6')])
self.add(daemon) self.add(daemon)
webserver = Webserver('webserver-onion-location', webserver = Webserver('webserver-onion-location',
@ -113,8 +107,7 @@ class TorApp(app_module.App):
update_hidden_service_domain() update_hidden_service_domain()
def disable(self): def disable(self):
"""Disable APT use of Tor before disabling.""" """Disable the app and remove HS domain."""
privileged.configure(apt_transport_tor=False)
super().disable() super().disable()
update_hidden_service_domain() update_hidden_service_domain()
@ -166,21 +159,12 @@ class TorApp(app_module.App):
'passed' if len(hs_hostname) == 56 else 'failed' '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 return results
def setup(self, old_version): def setup(self, old_version):
"""Install and configure the app.""" """Install and configure the app."""
super().setup(old_version) super().setup(old_version)
privileged.setup(old_version) privileged.setup(old_version)
if not old_version:
privileged.configure(apt_transport_tor=True)
update_hidden_service_domain(utils.get_status()) update_hidden_service_domain(utils.get_status())
# Enable/disable Onion-Location component based on app 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') daemon_component = self.get_component('daemon-tor')
component = self.get_component('webserver-onion-location') component = self.get_component('webserver-onion-location')
if daemon_component.is_enabled(): if daemon_component.is_enabled():
logger.info('Enabling Onion-Location component')
component.enable() component.enable()
else: else:
logger.info('Disabling Onion-Location component')
component.disable() 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: if not old_version:
logger.info('Enabling Tor app')
self.enable() self.enable()
def uninstall(self): def uninstall(self):
@ -234,23 +233,3 @@ def _diagnose_control_port():
negate=negate)) negate=negate))
return results 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

View File

@ -70,8 +70,8 @@ def bridges_validator(bridges):
raise validation_error raise validation_error
class TorForm(forms.Form): # pylint: disable=W0232 class TorCommonForm(forms.Form):
"""Tor configuration form.""" """Tor common configuration form."""
use_upstream_bridges = forms.BooleanField( use_upstream_bridges = forms.BooleanField(
label=_('Use upstream bridges to connect to Tor network'), label=_('Use upstream bridges to connect to Tor network'),
required=False, help_text=_( required=False, help_text=_(
@ -87,6 +87,10 @@ class TorForm(forms.Form): # pylint: disable=W0232
'https://bridges.torproject.org/</a> and copy/paste the bridge ' 'https://bridges.torproject.org/</a> and copy/paste the bridge '
'information here. Currently supported transports are none, ' 'information here. Currently supported transports are none, '
'obfs3, obfs4 and scamblesuit.'), validators=[bridges_validator]) 'obfs3, obfs4 and scamblesuit.'), validators=[bridges_validator])
class TorForm(TorCommonForm):
"""Tor configuration form."""
relay_enabled = forms.BooleanField( relay_enabled = forms.BooleanField(
label=_('Enable Tor relay'), required=False, help_text=format_lazy( label=_('Enable Tor relay'), required=False, help_text=format_lazy(
_('When enabled, your {box_name} will run a Tor relay and donate ' _('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 ' 'services (such as wiki or chat) without revealing its '
'location. Do not use this for strong anonymity yet.'), 'location. Do not use this for strong anonymity yet.'),
box_name=_(cfg.box_name))) 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): def clean(self):
"""Validate the form for cross-field integrity.""" """Validate the form for cross-field integrity."""

View File

@ -44,11 +44,11 @@ clients = [{
backup = { backup = {
'config': { 'config': {
'directories': ['/etc/tor/'], 'directories': ['/etc/tor/instances/plinth/'],
'files': [str(privileged.TOR_APACHE_SITE)] 'files': [str(privileged.TOR_APACHE_SITE)]
}, },
'secrets': { 'secrets': {
'directories': ['/var/lib/tor/', '/var/lib/tor-instances/'] 'directories': ['/var/lib/tor-instances/plinth/']
}, },
'services': ['tor@plinth'] 'services': ['tor@plinth']
} }

View File

@ -2,6 +2,7 @@
"""Configure Tor service.""" """Configure Tor service."""
import codecs import codecs
import logging
import os import os
import pathlib import pathlib
import re import re
@ -15,20 +16,29 @@ import augeas
from plinth import action_utils from plinth import action_utils
from plinth.actions import privileged 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' SERVICE_FILE = '/etc/firewalld/services/tor-{0}.xml'
TOR_CONFIG = '/files/etc/tor/instances/plinth/torrc' SERVICE_NAME = f'tor@{INSTANCE_NAME}'
TOR_STATE_FILE = '/var/lib/tor-instances/plinth/state' TOR_CONFIG = f'/etc/tor/instances/{INSTANCE_NAME}/torrc'
TOR_AUTH_COOKIE = '/var/run/tor-instances/plinth/control.authcookie' 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' TOR_APACHE_SITE = '/etc/apache2/conf-available/onion-location-freedombox.conf'
logger = logging.getLogger(__name__)
@privileged @privileged
def setup(old_version: int): def setup(old_version: int):
"""Setup Tor configuration after installing it.""" """Setup Tor configuration after installing it."""
if old_version and old_version <= 4: if old_version:
if old_version <= 4:
_upgrade_orport_value() _upgrade_orport_value()
if old_version <= 6:
_remove_proxy()
return return
_first_time_setup() _first_time_setup()
@ -37,50 +47,39 @@ def setup(old_version: int):
def _first_time_setup(): def _first_time_setup():
"""Setup Tor configuration for the first time setting defaults.""" """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 # Disable default tor service. We will use tor@plinth instance
# instead. # instead.
_disable_apt_transport_tor() action_utils.service_disable('tor@default')
action_utils.service_disable('tor')
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 # Remove line starting with +SocksPort, since our augeas lens
# doesn't handle it correctly. # doesn't handle it correctly.
with open('/etc/tor/instances/plinth/torrc', 'r', with open(TOR_CONFIG, 'r', encoding='utf-8') as torrc:
encoding='utf-8') as torrc:
torrc_lines = torrc.readlines() torrc_lines = torrc.readlines()
with open('/etc/tor/instances/plinth/torrc', 'w', with open(TOR_CONFIG, 'w', encoding='utf-8') as torrc:
encoding='utf-8') as torrc:
for line in torrc_lines: for line in torrc_lines:
if not line.startswith('+'): if not line.startswith('+'):
torrc.write(line) torrc.write(line)
aug = augeas_load() aug = augeas_load()
aug.set(TOR_CONFIG + '/SocksPort[1]', '[::]:9050') aug.set(TOR_CONFIG_AUG + '/ControlPort', '9051')
aug.set(TOR_CONFIG + '/SocksPort[2]', '0.0.0.0:9050')
aug.set(TOR_CONFIG + '/ControlPort', '9051')
_enable_relay(relay=True, bridge=True, aug=aug) _enable_relay(relay=True, bridge=True, aug=aug)
aug.set(TOR_CONFIG + '/ExitPolicy[1]', 'reject *:*') aug.set(TOR_CONFIG_AUG + '/ExitPolicy[1]', 'reject *:*')
aug.set(TOR_CONFIG + '/ExitPolicy[2]', 'reject6 *:*') aug.set(TOR_CONFIG_AUG + '/ExitPolicy[2]', 'reject6 *:*')
aug.set(TOR_CONFIG + '/VirtualAddrNetworkIPv4', '10.192.0.0/10') aug.set(TOR_CONFIG_AUG + '/HiddenServiceDir',
aug.set(TOR_CONFIG + '/AutomapHostsOnResolve', '1') f'/var/lib/tor-instances/{INSTANCE_NAME}/hidden_service')
aug.set(TOR_CONFIG + '/TransPort[1]', '127.0.0.1:9040') aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[1]', '22 127.0.0.1:22')
aug.set(TOR_CONFIG + '/TransPort[2]', '[::1]:9040') aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[2]', '80 127.0.0.1:80')
aug.set(TOR_CONFIG + '/DNSPort[1]', '127.0.0.1:9053') aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[3]', '443 127.0.0.1:443')
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.save() aug.save()
action_utils.service_enable('tor@plinth') action_utils.service_enable(SERVICE_NAME)
action_utils.service_restart('tor@plinth') action_utils.service_restart(SERVICE_NAME)
_update_ports() _update_ports()
# wait until hidden service information is available # 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. 443 is not possible in FreedomBox due it is use for other purposes.
""" """
logger.info('Upgrading ORPort value for Tor')
aug = augeas_load() aug = augeas_load()
if _is_relay_enabled(aug): if _is_relay_enabled(aug):
aug.set(TOR_CONFIG + '/ORPort[1]', '9001') aug.set(TOR_CONFIG_AUG + '/ORPort[1]', '9001')
aug.set(TOR_CONFIG + '/ORPort[2]', '[::]:9001') aug.set(TOR_CONFIG_AUG + '/ORPort[2]', '[::]:9001')
aug.save() 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 # Tor may not be running, don't try to read/update all ports
_update_port('orport', 9001) _update_port('orport', 9001)
action_utils.service_restart('firewalld') 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 @privileged
def configure(use_upstream_bridges: Optional[bool] = None, def configure(use_upstream_bridges: Optional[bool] = None,
upstream_bridges: Optional[str] = None, upstream_bridges: Optional[str] = None,
relay: Optional[bool] = None, relay: Optional[bool] = None,
bridge_relay: Optional[bool] = None, bridge_relay: Optional[bool] = None,
hidden_service: Optional[bool] = None, hidden_service: Optional[bool] = None):
apt_transport_tor: Optional[bool] = None):
"""Configure Tor.""" """Configure Tor."""
aug = augeas_load() aug = augeas_load()
@ -151,11 +167,6 @@ def configure(use_upstream_bridges: Optional[bool] = None,
elif hidden_service is not None: elif hidden_service is not None:
_disable_hs(aug=aug) _disable_hs(aug=aug)
if apt_transport_tor:
_enable_apt_transport_tor()
elif apt_transport_tor is not None:
_disable_apt_transport_tor()
@privileged @privileged
def update_ports(): def update_ports():
@ -166,12 +177,12 @@ def update_ports():
@privileged @privileged
def restart(): def restart():
"""Restart Tor.""" """Restart Tor."""
if (action_utils.service_is_enabled('tor@plinth', strict_check=True) if (action_utils.service_is_enabled(SERVICE_NAME, strict_check=True)
and action_utils.service_is_running('tor@plinth')): and action_utils.service_is_running(SERVICE_NAME)):
action_utils.service_restart('tor@plinth') action_utils.service_restart(SERVICE_NAME)
aug = augeas_load() aug = augeas_load()
if aug.get(TOR_CONFIG + '/HiddenServiceDir'): if aug.get(TOR_CONFIG_AUG + '/HiddenServiceDir'):
# wait until hidden service information is available # wait until hidden service information is available
tries = 0 tries = 0
while not _get_hidden_service()['enabled']: 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: def _are_upstream_bridges_enabled(aug) -> bool:
"""Return whether upstream bridges are being used.""" """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' return use_bridges == '1'
def _get_upstream_bridges(aug) -> str: def _get_upstream_bridges(aug) -> str:
"""Return upstream bridges separated by newlines.""" """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] bridges = [aug.get(match) for match in matches]
return '\n'.join(bridges) return '\n'.join(bridges)
def _is_relay_enabled(aug) -> bool: def _is_relay_enabled(aug) -> bool:
"""Return whether a relay is enabled.""" """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' return bool(orport) and orport != '0'
def _is_bridge_relay_enabled(aug) -> bool: def _is_bridge_relay_enabled(aug) -> bool:
"""Return whether bridge relay is enabled.""" """Return whether bridge relay is enabled."""
bridge = aug.get(TOR_CONFIG + '/BridgeRelay') bridge = aug.get(TOR_CONFIG_AUG + '/BridgeRelay')
return bridge == '1' return bridge == '1'
@ -272,8 +283,8 @@ def _get_hidden_service(aug=None) -> dict[str, Any]:
if not aug: if not aug:
aug = augeas_load() aug = augeas_load()
hs_dir = aug.get(TOR_CONFIG + '/HiddenServiceDir') hs_dir = aug.get(TOR_CONFIG_AUG + '/HiddenServiceDir')
hs_port_paths = aug.match(TOR_CONFIG + '/HiddenServicePort') hs_port_paths = aug.match(TOR_CONFIG_AUG + '/HiddenServicePort')
for hs_port_path in hs_port_paths: for hs_port_path in hs_port_paths:
port_info = aug.get(hs_port_path).split() port_info = aug.get(hs_port_path).split()
@ -300,14 +311,13 @@ def _get_hidden_service(aug=None) -> dict[str, Any]:
def _enable(): def _enable():
"""Enable and start the service.""" """Enable and start the service."""
action_utils.service_enable('tor@plinth') action_utils.service_enable(SERVICE_NAME)
_update_ports() _update_ports()
def _disable(): def _disable():
"""Disable and stop the service.""" """Disable and stop the service."""
_disable_apt_transport_tor() action_utils.service_disable(SERVICE_NAME)
action_utils.service_disable('tor@plinth')
def _use_upstream_bridges(use_upstream_bridges: Optional[bool] = None, 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() aug = augeas_load()
if use_upstream_bridges: if use_upstream_bridges:
aug.set(TOR_CONFIG + '/UseBridges', '1') aug.set(TOR_CONFIG_AUG + '/UseBridges', '1')
else: else:
aug.set(TOR_CONFIG + '/UseBridges', '0') aug.set(TOR_CONFIG_AUG + '/UseBridges', '0')
aug.save() aug.save()
@ -335,16 +345,16 @@ def _set_upstream_bridges(upstream_bridges=None, aug=None):
if not aug: if not aug:
aug = augeas_load() aug = augeas_load()
aug.remove(TOR_CONFIG + '/Bridge') aug.remove(TOR_CONFIG_AUG + '/Bridge')
if upstream_bridges: if upstream_bridges:
bridges = [bridge.strip() for bridge in upstream_bridges.split('\n')] bridges = [bridge.strip() for bridge in upstream_bridges.split('\n')]
bridges = [bridge for bridge in bridges if bridge] bridges = [bridge for bridge in bridges if bridge]
for bridge in bridges: for bridge in bridges:
parts = [part for part in bridge.split() if part] parts = [part for part in bridge.split() if part]
bridge = ' '.join(parts) 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') 'obfs3,scramblesuit,obfs4 exec /usr/bin/obfs4proxy')
aug.save() aug.save()
@ -362,20 +372,20 @@ def _enable_relay(relay: Optional[bool], bridge: Optional[bool],
use_upstream_bridges = _are_upstream_bridges_enabled(aug) use_upstream_bridges = _are_upstream_bridges_enabled(aug)
if relay and not use_upstream_bridges: if relay and not use_upstream_bridges:
aug.set(TOR_CONFIG + '/ORPort[1]', '9001') aug.set(TOR_CONFIG_AUG + '/ORPort[1]', '9001')
aug.set(TOR_CONFIG + '/ORPort[2]', '[::]:9001') aug.set(TOR_CONFIG_AUG + '/ORPort[2]', '[::]:9001')
elif relay is not None: elif relay is not None:
aug.remove(TOR_CONFIG + '/ORPort') aug.remove(TOR_CONFIG_AUG + '/ORPort')
if bridge and not use_upstream_bridges: if bridge and not use_upstream_bridges:
aug.set(TOR_CONFIG + '/BridgeRelay', '1') aug.set(TOR_CONFIG_AUG + '/BridgeRelay', '1')
aug.set(TOR_CONFIG + '/ServerTransportPlugin', aug.set(TOR_CONFIG_AUG + '/ServerTransportPlugin',
'obfs3,obfs4 exec /usr/bin/obfs4proxy') 'obfs3,obfs4 exec /usr/bin/obfs4proxy')
aug.set(TOR_CONFIG + '/ExtORPort', 'auto') aug.set(TOR_CONFIG_AUG + '/ExtORPort', 'auto')
elif bridge is not None: elif bridge is not None:
aug.remove(TOR_CONFIG + '/BridgeRelay') aug.remove(TOR_CONFIG_AUG + '/BridgeRelay')
aug.remove(TOR_CONFIG + '/ServerTransportPlugin') aug.remove(TOR_CONFIG_AUG + '/ServerTransportPlugin')
aug.remove(TOR_CONFIG + '/ExtORPort') aug.remove(TOR_CONFIG_AUG + '/ExtORPort')
aug.save() aug.save()
@ -388,11 +398,11 @@ def _enable_hs(aug=None):
if _get_hidden_service(aug)['enabled']: if _get_hidden_service(aug)['enabled']:
return return
aug.set(TOR_CONFIG + '/HiddenServiceDir', aug.set(TOR_CONFIG_AUG + '/HiddenServiceDir',
'/var/lib/tor-instances/plinth/hidden_service') f'/var/lib/tor-instances/{INSTANCE_NAME}/hidden_service')
aug.set(TOR_CONFIG + '/HiddenServicePort[1]', '22 127.0.0.1:22') aug.set(TOR_CONFIG_AUG + '/HiddenServicePort[1]', '22 127.0.0.1:22')
aug.set(TOR_CONFIG + '/HiddenServicePort[2]', '80 127.0.0.1:80') aug.set(TOR_CONFIG_AUG + '/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 + '/HiddenServicePort[3]', '443 127.0.0.1:443')
aug.save() aug.save()
_set_onion_header(_get_hidden_service(aug)) _set_onion_header(_get_hidden_service(aug))
@ -405,39 +415,12 @@ def _disable_hs(aug=None):
if not _get_hidden_service(aug)['enabled']: if not _get_hidden_service(aug)['enabled']:
return return
aug.remove(TOR_CONFIG + '/HiddenServiceDir') aug.remove(TOR_CONFIG_AUG + '/HiddenServiceDir')
aug.remove(TOR_CONFIG + '/HiddenServicePort') aug.remove(TOR_CONFIG_AUG + '/HiddenServicePort')
aug.save() aug.save()
_set_onion_header(None) _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): def _update_port(name, number):
"""Update firewall service information for single port.""" """Update firewall service information for single port."""
lines = """<?xml version="1.0" encoding="utf-8"?> lines = """<?xml version="1.0" encoding="utf-8"?>
@ -485,14 +468,14 @@ def augeas_load():
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD) augeas.Augeas.NO_MODL_AUTOLOAD)
aug.set('/augeas/load/Tor/lens', 'Tor.lns') aug.set('/augeas/load/Tor/lens', 'Tor.lns')
aug.set('/augeas/load/Tor/incl[last() + 1]', aug.set('/augeas/load/Tor/incl[last() + 1]', TOR_CONFIG)
'/etc/tor/instances/plinth/torrc')
aug.load() aug.load()
return aug return aug
def _set_onion_header(hidden_service): def _set_onion_header(hidden_service):
"""Set Apache configuration for the Onion-Location header.""" """Set Apache configuration for the Onion-Location header."""
logger.info('Setting Onion-Location header for Apache')
config_file = pathlib.Path(TOR_APACHE_SITE) config_file = pathlib.Path(TOR_APACHE_SITE)
if hidden_service and hidden_service['enabled']: if hidden_service and hidden_service['enabled']:
# https://community.torproject.org/onion-services/advanced/onion-location/ # https://community.torproject.org/onion-services/advanced/onion-location/
@ -512,10 +495,13 @@ def _set_onion_header(hidden_service):
@privileged @privileged
def uninstall(): def uninstall():
"""Remove create instances.""" """Remove plinth instance."""
directories = [ directories = [
'/etc/tor/instances/', '/var/lib/tor-instances/', f'/etc/tor/instances/{INSTANCE_NAME}/',
'/var/run/tor-instances/' f'/var/lib/tor-instances/{INSTANCE_NAME}/',
f'/var/run/tor-instances/{INSTANCE_NAME}/'
] ]
for directory in directories: for directory in directories:
shutil.rmtree(directory, ignore_errors=True) shutil.rmtree(directory, ignore_errors=True)
os.unlink(f'/var/run/tor-instances/{INSTANCE_NAME}.defaults')

View File

@ -11,7 +11,6 @@ _TOR_FEATURE_TO_ELEMENT = {
'relay': 'tor-relay_enabled', 'relay': 'tor-relay_enabled',
'bridge-relay': 'tor-bridge_relay_enabled', 'bridge-relay': 'tor-bridge_relay_enabled',
'hidden-services': 'tor-hs_enabled', 'hidden-services': 'tor-hs_enabled',
'software': 'tor-apt_transport_tor_enabled'
} }
pytestmark = [pytest.mark.apps, pytest.mark.domain, pytest.mark.tor] 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): class TestTorApp(functional.BaseAppTests):
app_name = 'tor' app_name = 'tor'
has_service = True has_service = False
has_web = 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): def test_set_tor_relay_configuration(self, session_browser):
"""Test setting Tor relay configuration.""" """Test setting Tor relay configuration."""
@ -52,13 +48,6 @@ class TestTorApp(functional.BaseAppTests):
enabled=True) enabled=True)
_assert_hidden_services(session_browser) _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 # TODO: Test more thoroughly by checking same hidden service is restored
# and by actually connecting using Tor. # and by actually connecting using Tor.
@pytest.mark.backups @pytest.mark.backups

View File

@ -14,14 +14,6 @@ from plinth.modules.tor import forms, utils
class TestTor: class TestTor:
"""Test cases for testing the Tor module.""" """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 @staticmethod
@patch('plinth.app.App.get') @patch('plinth.app.App.get')
@pytest.mark.usefixtures('needs_root', 'load_cfg') @pytest.mark.usefixtures('needs_root', 'load_cfg')
@ -29,7 +21,7 @@ class TestTor:
"""Test that get_status does not raise any unhandled exceptions. """Test that get_status does not raise any unhandled exceptions.
This should work regardless of whether tor is installed, or This should work regardless of whether tor is installed, or
/etc/tor/torrc exists. /etc/tor/instances/plinth/torrc exists.
""" """
utils.get_status() utils.get_status()

View File

@ -1,21 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Tor utility functions.""" """Tor utility functions."""
import itertools
import augeas
from plinth import app as app_module from plinth import app as app_module
from plinth.daemon import app_is_running from plinth.daemon import app_is_running
from plinth.modules.names.components import DomainName from plinth.modules.names.components import DomainName
from . import privileged 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): def get_status(initialized=True):
"""Return current Tor status.""" """Return current Tor status."""
@ -53,50 +44,4 @@ def get_status(initialized=True):
'hs_hostname': hs_info['hostname'], 'hs_hostname': hs_info['hostname'],
'hs_ports': hs_info['ports'], 'hs_ports': hs_info['ports'],
'hs_services': hs_services, '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

View File

@ -98,11 +98,6 @@ def __apply_changes(old_status, new_status):
arguments['hidden_service'] = new_status['hs_enabled'] arguments['hidden_service'] = new_status['hs_enabled']
needs_restart = True 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'] != \ if old_status['use_upstream_bridges'] != \
new_status['use_upstream_bridges']: new_status['use_upstream_bridges']:
arguments['use_upstream_bridges'] = new_status['use_upstream_bridges'] arguments['use_upstream_bridges'] = new_status['use_upstream_bridges']

View File

@ -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 <a href="https://www.torproject.org/">Tor '
'Project</a> website. For best protection when web surfing, the '
'Tor Project recommends that you use the '
'<a href="https://www.torproject.org/download/download-easy.html.en">'
'Tor Browser</a>.'),
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

View File

@ -0,0 +1 @@
plinth.modules.torproxy

View File

@ -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.'))

View File

@ -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']
}

View File

@ -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')

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="512"
height="512"
id="图层_1"
class="mozwebext"
sodipodi:docname="tor.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata27696">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs27694" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1956"
inkscape:window-height="1329"
id="namedview27692"
showgrid="false"
inkscape:zoom="0.41594518"
inkscape:cx="-376.85156"
inkscape:cy="-302.70084"
inkscape:window-x="1463"
inkscape:window-y="369"
inkscape:window-maximized="0"
inkscape:current-layer="图层_1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<g
transform="matrix(2.7708177,0,0,2.7708177,-452.25352,-177.84153)"
id="layer3"
style="display:inline">
<g
id="layer5">
<path
d="m 264.513,77.977773 -4.917,19.529001 c 6.965,-13.793001 18.027,-24.172001 30.729,-33.323001 -9.287,10.789 -17.754,21.579001 -22.944,32.368001 8.741,-12.292001 20.486,-19.120001 33.733,-23.627001 -17.618,15.706001 -31.60228,32.559277 -42.25528,49.494277 l -8.467,-3.687 c 1.501,-13.521 6.60928,-27.369276 14.12128,-40.754277 z"
id="path2554"
style="fill:#abcd03"
inkscape:connector-curvature="0" />
<path
d="m 241.90113,115.14152 16.116,6.68594 c 0,4.098 -0.33313,16.59703 2.22938,20.28403 26.80289,34.5191 22.29349,103.71329 -5.42951,105.48829 -42.21656,0 -58.317,-28.679 -58.317,-55.03801 0,-24.037 28.816,-40.016 46.025,-54.219 4.37,-3.824 3.61113,-12.27525 -0.62387,-23.20125 z"
id="path2534"
style="fill:#fffcdb"
inkscape:connector-curvature="0" />
<path
d="m 258.02197,121.58695 5.80803,2.96282 c -0.546,3.823 0.273,12.292 4.096,14.476 16.936,10.516 32.914,21.988 39.197,33.46 22.398,40.42601 -15.706,77.84601 -48.62,74.29501 17.891,-13.248 23.081,-40.42501 16.389,-70.06201 -2.731,-11.609 -6.966,-22.125 -14.478,-34.007 -3.25421,-5.83246 -2.11803,-13.06582 -2.39203,-21.12482 z"
id="path2536"
style="fill:#7d4698"
inkscape:connector-curvature="0" />
</g>
<g
id="layer4"
style="display:inline">
<path
d="m 255.226,120.58877 12.018,1.639 c -3.551,11.745 6.966,19.939 10.38,21.852 7.64801,4.234 15.02301,8.604 20.89601,13.93 11.063,10.106 17.345,24.31 17.345,39.333 0,14.886 -6.829,29.226 -18.301,38.786 -10.789,9.014 -25.67501,12.838 -40.15201,12.838 -9.014,0 -17.072,-0.409 -25.812,-3.278 -19.939,-6.692 -34.826,-23.763 -36.055,-44.25 -1.093,-15.979 2.458,-28.134 14.887,-40.835 6.418,-6.692 19.393,-14.34 28.271,-20.486 4.371,-3.005 9.014,-11.473 0.136,-27.451 l 1.776,-1.366 13.15659,8.81203 -11.10759,-4.57803 c 0.956,1.366 3.551,7.512 4.098,9.287 1.229,5.053 0.683,9.971 -0.41,12.155 -5.599,10.107 -15.159,12.838 -22.124,18.574 -12.292,10.106 -25.676,18.164 -24.174,45.888 0.683,13.657 11.336,30.319 27.314,38.104 9.014,4.371 19.394,6.146 29.91,6.692 9.423,0.41 27.45101,-5.19 37.28401,-13.384 10.516,-8.74 16.389,-21.988 16.389,-35.508 0,-13.658 -5.463,-26.632 -15.706,-35.783 -5.873,-5.326 -15.56901,-11.745 -21.57801,-15.16 -6.009,-3.414 -13.521,-12.974 -11.063,-22.124 z"
id="path2538"
inkscape:connector-curvature="0" />
<path
d="m 251.539,140.80177 c -1.229,6.283 -2.595,17.618 -8.058,21.852 -2.322,1.638 -4.644,3.278 -7.102,4.916 -9.833,6.693 -19.667,12.974 -24.173,29.09 -0.956,3.415 -0.136,7.102 0.684,10.516 2.458,9.833 9.423,20.486 14.886,26.769 0,0.273 1.093,0.956 1.093,1.229 4.507,5.327 5.873,6.829 22.944,10.652 l -0.41,1.913 c -10.243,-2.731 -18.71,-5.189 -24.037,-11.336 0,-0.136 -0.956,-1.093 -0.956,-1.093 -5.736,-6.556 -12.702,-17.481 -15.296,-27.724 -0.956,-4.098 -1.775,-7.238 -0.683,-11.473 4.643,-16.661 14.75,-23.217 24.993,-30.182 2.322,-1.502 5.053,-2.869 7.238,-4.644 4.233,-3.14 6.554,-12.701 8.877,-20.485 z"
id="path2540"
inkscape:connector-curvature="0" />
<path
d="m 255.90625,166.74951 c 0.137,7.102 -0.55625,10.66475 1.21875,15.71875 1.092,3.004 4.782,7.1015 5.875,11.0625 1.502,5.327 3.138,11.19901 3,14.75001 0,4.09799 -0.25625,11.74249 -2.03125,19.93749 -1.35362,6.77108 -4.47323,12.58153 -9.71875,15.875 -5.37327,-1.10644 -11.68224,-2.99521 -15.40625,-6.1875 -7.238,-6.282 -13.64875,-16.7865 -14.46875,-25.9375 -0.682,-7.51099 6.27275,-18.5885 15.96875,-24.1875 8.194,-4.78 10.1,-10.22775 11.875,-18.96875 -2.458,7.648 -4.7665,14.05925 -12.6875,18.15625 -11.472,6.009 -17.3585,16.09626 -16.8125,25.65625 0.819,12.291 5.7415,20.6195 15.4375,27.3125 4.097,2.868 11.75125,5.89875 16.53125,6.71875 v -0.625 c 3.62493,-0.67888 8.31818,-6.63267 10.65625,-14.6875 2.049,-7.238 2.85675,-16.502 2.71875,-22.37499 -0.137,-3.414 -1.643,-10.80801 -4.375,-17.50001 -1.502,-3.687 -3.8095,-7.37375 -5.3125,-9.96875 -1.637,-2.597 -1.64875,-8.195 -2.46875,-14.75 z"
id="path2542"
inkscape:connector-curvature="0" />
<path
d="m 255.09375,193.53076 c 0.136,4.78 2.056,10.90451 2.875,17.18751 0.684,4.64399 0.387,9.30824 0.25,13.40624 -0.13495,4.74323 -1.7152,13.24218 -3.875,17.375 -2.03673,-0.93403 -2.83294,-1.99922 -4.15625,-3.71875 -1.638,-2.322 -2.75075,-4.644 -3.84375,-7.375 -0.819,-2.049 -1.7765,-4.394 -2.1875,-7.125 -0.546,-4.097 -0.393,-10.5065 4.25,-17.06249 3.551,-5.19001 4.36475,-5.58476 5.59375,-11.59376 -1.64,5.326 -2.8625,5.869 -6.6875,10.37501 -4.233,4.917 -4.9375,12.15924 -4.9375,18.03124 0,2.459 0.9805,5.18725 1.9375,7.78125 1.092,2.732 2.02925,5.452 3.53125,7.5 2.25796,3.32082 5.14798,5.20922 6.5625,5.5625 0.009,0.002 0.022,-0.002 0.0312,0 0.0303,0.007 0.0649,0.0255 0.0937,0.0312 v -0.15625 c 2.64982,-2.95437 4.24444,-5.88934 4.78125,-8.84375 0.683,-3.551 0.84,-7.10975 1.25,-11.34375 0.409,-3.551 0.11225,-8.334 -0.84375,-13.24999 -1.365,-6.146 -3.669,-12.41226 -4.625,-16.78126 z"
id="path2544"
inkscape:connector-curvature="0" />
<path
d="m 255.499,135.06577 c 0.137,7.101 0.683,20.35 2.595,25.539 0.546,1.775 5.599,9.56 9.149,18.983 2.459,6.556 3.005,12.565 3.415,14.34 1.639,7.785 -0.41,20.896 -3.142,33.324 -1.365,6.692 -6.009,15.023 -11.335,18.301 l -1.092,1.912 c 3.005,-0.137 10.379,-7.375 12.974,-16.389 4.371,-15.296 6.146,-22.398 4.098,-39.333 -0.273,-1.64 -0.956,-7.238 -3.551,-13.248 -3.824,-9.151 -9.287,-17.891 -9.969,-19.667 -1.23,-2.867 -2.869,-15.295 -3.142,-23.762 z"
id="path2550"
inkscape:connector-curvature="0" />
<path
d="m 258.06151,125.35303 c -0.40515,7.29812 -0.51351,9.98574 0.85149,15.31174 1.502,5.873 9.151,14.34 12.292,24.037 6.009,18.574 4.507,42.884 0.136,61.867 -1.638,6.691 -9.424,16.389 -17.208,19.529 l 5.736,1.366 c 3.141,-0.137 11.198,-7.648 14.34,-16.252 5.052,-13.521 6.009,-29.636 3.96,-46.571 -0.137,-1.639 -2.869,-16.252 -5.463,-22.398 -3.688,-9.15 -10.244,-17.345 -10.926,-19.119 -1.228,-3.005 -3.92651,-9.24362 -3.71849,-17.77074 z"
id="path2552"
inkscape:connector-curvature="0" />
<rect
width="0.550412"
height="126.01891"
x="253.71959"
y="120.21686"
id="rect2556" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -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 <http://www.gnu.org/licenses/>.
*
* @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);

View File

@ -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 %}
<script type="text/javascript" src="{% static 'torproxy/torproxy.js' %}"></script>
{% endblock %}

View File

@ -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

View File

@ -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()

View File

@ -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'),
]

View File

@ -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

View File

@ -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()