From b0c75b7849cafeb5375a65bb98e8fb0e567f30ac Mon Sep 17 00:00:00 2001 From: James Valleroy Date: Fri, 9 Jun 2023 15:37:21 -0400 Subject: [PATCH] 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 --- plinth/modules/tor/__init__.py | 77 +++--- plinth/modules/tor/forms.py | 14 +- plinth/modules/tor/manifest.py | 4 +- plinth/modules/tor/privileged.py | 202 ++++++++-------- plinth/modules/tor/tests/test_functional.py | 13 +- plinth/modules/tor/tests/test_tor.py | 10 +- plinth/modules/tor/utils.py | 55 ----- plinth/modules/tor/views.py | 5 - plinth/modules/torproxy/__init__.py | 130 +++++++++++ .../share/freedombox/modules-enabled/torproxy | 1 + plinth/modules/torproxy/forms.py | 19 ++ plinth/modules/torproxy/manifest.py | 52 +++++ plinth/modules/torproxy/privileged.py | 219 ++++++++++++++++++ plinth/modules/torproxy/static/icons/tor.png | Bin 0 -> 64196 bytes plinth/modules/torproxy/static/icons/tor.svg | 112 +++++++++ plinth/modules/torproxy/static/torproxy.js | 33 +++ .../modules/torproxy/templates/torproxy.html | 12 + plinth/modules/torproxy/tests/__init__.py | 0 .../modules/torproxy/tests/test_functional.py | 64 +++++ .../modules/torproxy/tests/test_torproxy.py | 33 +++ plinth/modules/torproxy/urls.py | 13 ++ plinth/modules/torproxy/utils.py | 75 ++++++ plinth/modules/torproxy/views.py | 104 +++++++++ 23 files changed, 999 insertions(+), 248 deletions(-) create mode 100644 plinth/modules/torproxy/__init__.py create mode 100644 plinth/modules/torproxy/data/usr/share/freedombox/modules-enabled/torproxy create mode 100644 plinth/modules/torproxy/forms.py create mode 100644 plinth/modules/torproxy/manifest.py create mode 100644 plinth/modules/torproxy/privileged.py create mode 100644 plinth/modules/torproxy/static/icons/tor.png create mode 100644 plinth/modules/torproxy/static/icons/tor.svg create mode 100644 plinth/modules/torproxy/static/torproxy.js create mode 100644 plinth/modules/torproxy/templates/torproxy.html create mode 100644 plinth/modules/torproxy/tests/__init__.py create mode 100644 plinth/modules/torproxy/tests/test_functional.py create mode 100644 plinth/modules/torproxy/tests/test_torproxy.py create mode 100644 plinth/modules/torproxy/urls.py create mode 100644 plinth/modules/torproxy/utils.py create mode 100644 plinth/modules/torproxy/views.py 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 0000000000000000000000000000000000000000..e23f23312e58aa918dd34fd92aa6ec5e90c2033a GIT binary patch literal 64196 zcmX6^WmKD8v&9J>thl#Wf#R+~i@Q4%cXzkqP~6?!-HN-r7k8Inm-o9XKaw9=$r;(R z_sl%!x4f)43L-ut1Ox=iPYDsl&-Vui2uKS6+~+5n-eM652ynzt5g}za-Lp*Bj7&B6 zL*K)Wx6aP-_2Ya7je(}GS}j^tbwt87LPg(#dJIG9bV>Qrb&TTAiUhx~8~^Q#2P%?o z`(c9WA^(N6TeX(BnuEvBAI~qjOxX?{3*&;3zO4*Aykt&Y)3Z44`*z*(9%dZ!hHkS- zfC7L45Gf=9K=QU~`!OyNqG3VfEeQHip||tLoG3L?tZEqyj8YYdRRs9=v!#&nweIyl z_-=e6x81@>lflQtuz$Z}!B*3MeuoAE({CY>JELEm&YO=8gsvTH(e2#D2A7AaF zo0a(!#PIW8b0Tx$;XCN$e-7c=^~`*vF&y;+J_SwbDv zHL11fAcrJy``?LzDa zHNpV&Ck@I}BdV{8QoG&nn=9pr!PuwbFICzGGm35dM787>Pl)rj#1+2go;2RNtG1_^ z1nk}pr~>U@(Rs*zb~k^F95#MD>`Pw6e5hzqS^atw+;J{Pj#nU<riFH)D2St7~ED~&v8ru2F*71w?hFg&$5D(?1KYS-Tl{wX0*@O~zc zwRF2rvR?CkD%X80;Ojee>2+HAH{0iRgd!U}y3xM*G2rV1rbqK~eGJX=+?y5`c$iw- zY&iS#m;k<5e{g*By_p(7e$Q;hBWQGY7q}x+=kUdUI9SW|*?rTBT(i-#{do8o{jzzh zfmL`lMMXxoqvV>g@R2yQ1wOdEc)ynuu-kqZ%Hjtv8MRYhmhgF8#YB7Gw(4!ZWa=~% zd*5w~ci(36eH`b-Z2o%mdF9z3Esl=3zofrwT$&tQZ9ZK6#Tc*r%8G&UzC)lE$)M~0-dK1RpUyh4YE?Ec5MK6$VDnv? zDy=ld!hqB$G6=D;i)F><$&9c}!LDGt>+&(%;iFSht@|zbBT>sIR8<#@T2DpD?hwID zaTcQDi><5voOuT6+T^;JIlh-bt_p99*hJ^;TesmH5ESJ5dixQ7(XOslwETLQGc85h zx1XR1o2aQeo?b@60Vou!hHmQQ2a;;h#rl`^O#1BCZ(|9G4HaT*2ngEEgfz^b(B#_UYYz zX36dGCEp$Vn@6=zaOrBYSVw(yguWjJ8#Gx(ESm}g2!#DdA=jBT-h&paGKPfHLhm3rDQ-wZU}d^RklPr~{5HTjE+6!$W6bi;R-!&20~heo8~=w6r8Ll6pf088~e zKfRzRmC8?_wY}wY1RXI!&M>dy={UYo-wN=gQI0@RLsE}GL>DfkJQOSd7Up8y*B&4E z!)JpJ`p-U|NMdR+U)BX=9fcuCgTgRB(Da@kd>Pyakid1vU5%PaRZ_>;!GEX`y_bx6 zqooU3m(mH$#L=fj<|i<7seR65L#LwTw66z07GZ$dPvc7#Kg~e_3E&%F!jU@$0v+@; z!%Tw$-D1oaFF!OtJ06oN{5n*0@-GP%85k4=(`#k%J|Ut{ck5^!mm5!60#?xS?F#Xo zk<*-_ym+|+fz~r5J~qPUZyMEl6@`ujr9Fb9M+(e?5QY~H0yalvxA@#M(Lh{rD;X+c zw1qIwT8_1>w%rUgLTKR6R6X-VQv?L9W)KNC$>_3a@5Sy-4{L2~9{hUWr>uz{Kd!%&3i zA#8uC;+(*0^7}?&1ky3+7pwAHV_J>4b!D>g9U{^$#A)K|IG$Eb_em;?>mb7k98_Yql9$JwH+G~SqIQK?wc zl?A*TsxV?krAC&X)6w|Ffn>=`Unoy-$+6Gdq zaD-VF1THx1ox z)3GwJvG_sm(*5ZwV7^+u78Ap!3$MwAosJ&La-Pgbv-Hya2rX2}l=_f5ya^+KP8Hh{ z8mBjwWQp|?`C+I-=V2+$Mk?YmVNWYz*Hr4mB%`XK#I3vF4}SPp^tLO^nWqLWR)IJh zf+d^$heF1;!q(i)@pykpgP9yL2Wa%i?@3AYXvV5&Ns19lAbs08fh1F2^#eFZBgBOw zx_UQUQt{Js9i?`vZqK{lnR=FOdTCC2*TwA^7#(yH#{Ti_phKpsY_XGQi4R(0tW@6A@5 z>g3WTEt*NTf9rlkhB9JyiEq10RjW=K^!s!jrn!K=}!qId>dCxB_l-uO@N zpi2^zB0R?);`4F~tbW*hpU{tQRY-&zN5IZIo3wmGd)Q|`yX#x65+CBXcRL(6+T2sB zavVB8>e!w7lrNm@%8h8pd7-L$NKgWhzFH;L&jGQspdd8s;C-FY@L44EsNCyRt?Acm%P!lW>B(XQ zR&2iZ>y_{aq6b8(aepRf{C%Ey}DPq8YnjK*AKkB zpU<@TuL=0SkQUqOY&=Nin}83eaK|5u)RtAJ1Z7Y07FmsA9oa{FlM!o(LD<4%X%=PX zP@pgzwrvp=Mq(i{4{1SDwmbpdpVeOdImc98u1`iUXtDw)E3|IhV%l$n8FD^Iz2CV% z$z)Bdk|QtKvc{4BgdFZ~%t*0K`{bmaVMaMYD+g_O9arRGNI;K_^LpE&RdX0fMR9ly z07YUmjnQO1;2o@Dl(33MQnKM&qPtZe<1?J*aay&791|fU7pIw#7XJ|%qdNKk?!AX@ z9WY&1&vgu`(}zs+VkpH+hF z5WiS`xrUy*H|=vTB|@8t`+zew;Mm5dbpa`&eCe>)iUaN`iXa&&#YFWO2uxlUZ}JwJ z=w)k`YpyQ%c@`2hgcAK|;FgiIC(Nb}56Q}6HT=?xMr5Etdph#Nb{V|=7HV?2vSD?( zhPi=F*kXQ2G$RASC2HaGa#Fd<(bSQB<~3KPY*J86#hxiMFfZ6`o$Od-_yaYV1J~X+ z6*ON5a*k4z96i`kvNVY3jk3m*_NC)^Rs$Oyb#%_pM~A!Z{;G3_o3 zy_V{U{lnh6r6VJIiCDV}3Fck6(^eocgX<-9Mf3kCQGD!l)h1Zh)Lb<})2%E&VWT%S zDVRA*S>TehUxlnyqV)K#Va6Sb;{lqZ`Cb&d#$~kUe|+%Jeo)j*a^hX!dnxnr&!_u) zJttF_`$Q#p{J^sj{)Z~!8#KX^>Fi457os|ZLiU)Bo!D(Fka;)MLVGQTRuo@Z^%hcb3v-)W3mqr<^XgZN^{s$~C)5rU6PI{&9arw8=hE;msn1OW zrRREj;FH`HTauN>&cUp7eur*&Kgry7!b^7(AIDQu zsbi^H@(mt012UGDNyZydYFz!V{1GLil#&TTuxa!?iKI4|RB3?(3qVGAZASSr5E(bX z>@4)$f+uAIS}2RyE^EIizM5x^-bc zaz>!h^+Wdk(3b(!flT6Yo8$ZduUdsiZQat6LhK9MtO(aqNj?-EgFdVk4=oN{xs-i)--#C-%8kGe=4 zEJMLEEXvzciaWC)-7VQ@HSRqSlOA*}A5PcPL)IgT3=qs!_KWBIV(=HG{Dt(%zd@7h z%>BGG=Hg|R%(rf(rx{2`7vG84FDr4dxbv~93Q9x8o~6i7RrVcJ9j%=}fg6b+iFjQO zg{$mMpX9Dj&~Q;0rPZEwiq3SuTlTSZDH6t|_?pNxvz0AOX-XRD1xcQgi|4aQ%wIM; zz7#F}F~;s&#Q$z-TQ<2XvmFH7JeRg0<~8-Ku47XvEfB+@r?=8HAb5rp`UxvSw}54Y zEAo?t*ix2NxT6J@fs?f?5QsR*MZN>A-6KD%O`+P4mwm5(%LvV561NUH_|G(($7{;* zbtbSr`xQ4sO-1iID9G-XeL$joKOvQbe%ag7nNPqZ#QmdrtA2^g`Qz!%DCRpqYvIFI zi;E0d^B%J(E>2TMtz)q%Roy}>>ONtAgR(N1S>l&6OfD55S9(r)OL^3&OtHnW?0MRi z(j*(dKD(pVpCjwZ6y|M0KqC%ul5ahE1Uvabs~qg<@I4e3d(3-A(>(SpRx%0-XZ%V% z%1wa?{V@lKl9mPqvuD8*hz0}*%h*S)^5adys8jV;)#*webBz`qAC`6 zw|A><>)J*5*|4}35|G8BrlM$C!F~_QmbpD|&@e2{j^|E9X^yfq?YKQ$in-H!a_Otc zb`4m$wTK!q>^z~RgUUq|9bMGdMC{|lBHb$%&A@-WOPf4by3<#c$hbBZxF1%+ z-{7R_QHWVswNO}B*@6^E0c4R8%g(#23RD>z#P#~2g@=zR-irruv zsGkz!n?=VOMhyb>hCc@{G)znIsH*Q(LIPq|Cy}O-d z*HUQ*`q(t11h4sIqoGA}8R=l9`=?b@N@$~~XEYHf`0Wa=_XJeZ(Nt8v|9i0*<*NhB zaz8t+``p>quXtTfhiA=SNvbTUmWA*`q5MUh3Bih+vKwq{;}Bb~`OS#DN84;|nnOcL zxFl4*0m2FmDATqxVa9ID$GEK_Y0fsJj3mj=myU)H`$MJALb zr#h*Iv(~zKzkjw{uVQgWAbE0K$Hpyu==1<8Ovia0k@TJk&R>EzJUH+;9L8Vz%`8~q zt}C*39+okTu965^KyZ@5POD{J8R_g%(wV5d@lNw`FF$gtP?SWw zyW3N(2oI{~r<{!cXB3AS*Ric^#7pV3975s&1oh0*6;#k+mE|nh5R+H~`hxN>v+9`b zH_uAV!&|?Har5*I^Yk0}NJllA+OFRf!w-94bX#0rNJ`*t0HO}-%0uhQr`XLb=rM)9 z!th}5l)y%lWMu_(`;IB*+e*1(=3jVvSJy8(|3fAVA5F=b@Q~OA2Gss|dsIC(zKp}1O&V!o5* zn_s>z5OlHE%C|nyV)Kyszj~RN+uA&9aMeF*PyVa;j1^7X43VX(HeV?Al|6p4m)kz# zfPVKG?0sU_kRnK4MFFOA354-Pn1BI*JmQWk-!4^_gb2efK~bXLoO7Y-TC^guU>6pl zr%%V^n$5SeL{|b2GEGvH40LtM+J}j)2g8 zO)wzM%~sPyRe#1Szj`<{q+Ktc%rJPhMoxbR@QC1tkgydH%d0sc9!hfAupHB=QoOc5;=nm>0n z)@z+*Wm<*fv-=@Q+}+1hRTipm()kDFxg?lm5R;ICJ#{F`V5#9fx~E*EB}YtDFOLCz z`}6&zBu)I9CJa#(|S;+ADQMt*X+Qg%2yQ3p@~NP515bvm+qv^pMx!kKHm(S>z&ft(Ok+2O6p_ zowdecpYlS$KzQ#qeN=L%XHC*;xgV%VH*8MdW%kNa)$MnrInGvuUUlNy5AVFxuCIPR)aw{6kH zs?~1&qBonLAL3iToOXS4da1+Gp##x?s!%WaC zVyLaYx-#TmuW5HTRVcVBW{s�?g+@vo;}NplC+pX%yIfU4mecn7m|wEF+ihId604 z9P!pgJ!J8auB-d1gfAmj6hWGgw^XN2_x$fPDbR&+!Tf@IDWoL8_#|_7T0mJ)P7hTX z_O(Y8H&~ucCMBttbpe1lxGk+f^7bxr-|Fk(@EU;`W!EN3xe|TVNicMUFDRNE6!fmQ zuJ1Jail3}96^5=EcO^myf1jbtRmbdoZsoq=hepb)?hCPBK1^$I)9l{6zH%@2T`3z$ zMP>mZMtHY@g2tJUskJe2gn&A+o)Luwb$3zwuYeQl_GUp*<^IZeIETYYXBLou&+R39d{A=<6xnS0Xux?WN?}{U7 zc>0j?9otQ?v%rvfM=(++2V&5UGe|`Ri>8qT#%xJA$RC0%7C@GhUbYVXsEq~%RaOR? zLpBSir8Y}or)>=2^0Z2MvYLJHSHnJu>5maW7gc0XHJsc+OMgC4Uk! z*+Bw?+B_Fyu}xY#MqtciCK_DNuKHT-{rh&bP3E|TRu%&K6MOdC{yixxQ0L-H;EI|p zKtI{QfWok}0DiPsX}J(Vf%1qt966=j)P(_d!N2bPJqAm>91r&beY>r1^eiGhq)`-X zeK4Y|1%sA=tmvddTeCTQra$l)CtfKh108_wX@{Q3pvr@&F5z$1=9PyT3-3!^54tkP zj^fz4dZ*75EclJlD`g=Ge&ZArN4yD$KuHL|-YsRbm37hTN^Rvi5ic&_O>og?!>~+4 z3pWDHL%(IlP81B3Uw|;+1r_7?hfb1|t$>dd_L{;?hDUl}vBvBxRb}MGoq)t0s z`T1NPx2#A$G_m`Tq@$aJgJ~~el#)cK)2$SC0kVP)W1KBZ%CfU(&B5V1Zm)9H= zE?QHIU~2b|t0Fqm{`B>ESzh9Ac09Y_R#+dF{d*{Q_`_EGkd3KYR`EJ$PD)5k+oLz! z%Hzr*hU4XJc&4K#3Q%aI(b>{$j5cM_8u`)Bl06R+V=tJW|FVUhzi_PnNGK!-gYXJ$ zs`OWc1rlbFKnLAQ3zddAoyD1We)ypBj2A3LEiY~-`m7V$aI8s|A7iK}3DF1<1u&s5{YHl)V^N$es6Ko5*;uRn8w0Jkr20@)8MpMJ z;Q6zBM&7a>^_qwhHVOn+jGfE>kNhqr^3{K6yMdw=rz#!M;Tz_P6>(D>BqS^*(^6uB zQR#p$7x&v8PK>m`8PE?M?S5)Kp!Mk?s<+vA=+6@A&wg3;I{OCS_%q&myvZ*eIr%{f zxPez1T5>XsMdO6&9u@oxuZA;qH#mh2Su&Yrx)#cOiG)E1;TXa&DdG(xgAuo^06Qb&3K!a_lQbo?X6GOd~$ zHgIN(I_UkGa7#lEW~B#l>SUFE$`uuj2eYI{ksew3tR~P6Cu9YUB0c7zW*lBz#YJV2 z^L3gN)7S-$_=uff>RPd+DPaBlkQg{cDwJNfwNIzJ`B|Jz(^JS^DOW2)cSiWPs(;0Y zWnNXZCti$R0f5~)8iM9ORMCs1fm!5sZ>_Wm=8!9HG4hGt0$bZQdp!Q z=B12)tv2LmES}8f`RoQ$lgy`z9M1DW3nrx-E*&6{| z9gCl#ei}@Y!?pQX=BncGJ{Vs&o^)-Er@>`pwdBVqwt)NRnby1H$cEFR)h)~XUM*R8dY{YcdQYF*lMDqmKa==Ol|`uH(F zJV@jWX1mSSSMcSKsM~pReSBWNbCHR;lyF9xv5a~S@-h47RYy7hS_}g@Q!kqhXF?Xt z4Ip#T3f@} zLlu=m!rVAg=T1xG4eV?4=rG28H_5Rqirj^88}6*Zl#OXzZx!eB^^iM|_qzMx3oj#q zfl(Vr_|S6NDxh^J*2oeQk&4NrqJ$Tvi2o8a$H!DYg==v>K1`!Kn;}z~mU(r~xmzG# z_c>w{1dx)#-gl~Zu}X(K6#6MwQDJ{KE(O-lp9U51@8@eE6@>^FZ#N zEYYO-*Jqg2j9ZH*8E7gut3XPwXXqXu8M~Y-vw6!}4ssUrV;>5Wux2vpuiyjxzaryl zQzf7$DU#|*=gzCasR^yh%9N@Sq+}iqdz(xC&iKJ2>!&&=&R{ySVAIQq73BvECk@+I zP0n1DFH;{bbgWr`N(plVa-(DRwuG&8lPiAIYS&$MvX@G~`xyq77N87t6Xe>NwsyM(DynLL}isB{zxU4xw#La^r+6vPi5bn4UF*yNJq^iER<&J(+CYXl@SZ091yAN-u)AEd!uP6G+2bBDZy^l5Q`(Vt#A0 zM(N%QTIivk{W+G`V5Il+8mKE@AF~;PtslEY`gnygemvZL+p4 z#9}n2FMNy(ysuN3nbt<&{s_s;*s#o4jYI~S*7m9D_CKkd+sb~RIjn+nEI^=#*;E3v zeS^@KALV|Ec;mSiv;aU5@W=_IY)aH>n|WEQZ;aBADN*I+IQc7RiB+ZOka-lzPcgtf zXxky{ys2Ow2s5o5?xZT7n<6IHm91sLaPqV;k|b~|vX=S|7C9`125mLMi6w14H~D#g{dwxZQm*nH^!jF+>AI(Ep%o`)`u%g#mf&xjhJ{dxuBD(suVm5sH7Ho&oZFBX$8i94t{V$=Mar) zH#eC7Xlbo<{Fbz>rvCLkj)c89W-;5%Wpw3!TKjf5{~fdPWX#I^+b2Tk+OKZtaykFh zFPmgcQyYIZAuKBVdkMqY;fdH*HM+S40+?;II&TkMtpm=Jto%rkB%-627}+=ds%RWy z()4qen#*W85&ZklMeH_k{UC;4V_hNCqudxXyq)^&EdK6<00o$9Dd z5qqFy9I=!PYqCV9&)7mi`4x>?GXcqEA87M8{t}(5LJYRNYEK45E_On5-96xF45v5# zD}Brg;dl=;aeQF-@O>SJ#=9}qywAHwqnFfXU8KF{&<1w|Jdn7`LogjhM2_L(Mj)& zVr}&7CWEZ(mGNlps13ZUyq{Vd0;uTp1!*sWGO7C9=s94;%9CZNkRKdq;dzra zIcX;j&L`-!PT6yF$M{rE6(IuPqYpyVofz)Qo^b7`aEZ85_888xIcqzHSV@99<@s^^ zZKzw26uLJ@k|=Eel=#r;&Adu!LIAJs$iBAr!&fbJSa|a1AyToY0Aw)bjUVJtcy`y z0?Q9WB^R)o%bQ}b!mZw4kfBWGU^Bs)oaab6CdQ=XSa2r%SG1LR5*NPE&%|j=26k@> zt}F8DsKM(=76xG6L8n=YThftLtra(`(;}^k(=zZ;_}&c zB`nS_mJJhJ?l3J-D^RNd(4p)!{jpZ-kozOYynm6A?nii;3r)dDjS`^Q>k@oH|bLT-0>Czl3?rG)AmSKi+?7;pl9m^;IRcjK@7N!mNQ7^=M z5~Y(*g%va-Rc?|ww@`Ij>@GqB3&0}1GYqmpUO@;(Sf5U;OgVPm%^pr*~OI z=R)lfZBJ0pm{m=gyfFE#`wh@FMj8YW(MpGRpHbtvV2!xXPmtJ4IQC^iIMeQC#>Dgt zKKIo6W9`v^P=Ep9CvBFKtBrUB3Y9L{a5_gzi zLfgunF?ZDo7X6TT{&7xme4L~878)d=RllgDu2i6-YTnQ9Qj}TqJECZ=alU%(v_Qr` zzRqAgI9IK|>0ZkQ6X%0X`fvjL|B;xbDw16$y+enK-jO3LRFFLRk9H$6=t7uW>1ON_ z+Qg|-XSLB%OGI+O_&j&r9_ydlY4aC-X^9=SzyMKghAITqpW8!q+)+SMt%g{7yra?r z(bDE+$lux4+)8LJX-aazTZmM?ePwl@&NOE0aNo2LwPRnkUtM7YCisDdTUp?Pu+a0? zAcOseg^4FZzK{L&j?M(yrVWb*WgfeC^^ya-iGbOV(h7d+ ze(p?)=0qZ!nct>MS^*sr;e?LSj3&b-^Z*894RcD{5mg&JdC}H*6zRWillQBxQw~R| zPg%X>%LL`nuVS6iY~)+>h@Oxulc-{}@SCD*w8T-#rY$_Con%}@W>{erTw!^U@gT?t zOqc>qs2v2>C(VYF&!~TCc=A=hpF=y!c~ezQ5F=y zO8WV!!eARBDJg2gTIf&`3$xKfweM~~1#^@+@^-zDcC9ItFng`$I>|3fVdRiYC^P2Z z8bwMN2oR0577su5&94Y-r03d{R#&1@KF=ShDv!#|t&>}?C{VBcU%xt@!OYy&>d;Ve zJE+!cOu^Ym|MMGbtu-mxCWGHWk^yoansE{n3IrG&hy51B9k@BkqK^D!-C|&&c*VJU zqd#+swJnsI1-;u04a{ow(U%F#AGv!b6f_9z|l`y>d*LygDQ&3nF)cA`_Wc z9AcJLC$VAI;k$v^L8mHhK%fnh{)>lG_rndh-+^Jn%(IRo)yDE>J9I5+E6~QGZrC`B z^S}>669s3XHQFni8m0`SuQ_@xG_7>;yDs&tUghx0+wo!<-CK_HZ6q7az4l+@1a8&C zXT$a`5da(`784!O>B~d9S75~P8o*AW&De~w64(&_breqUWRXV}zjc@LJd71g8t{o9T3ACmKm zJ6Nm3&Q@L4zQgEB$54Fl<>iP!ZoUjrT-NS|f)2e5Iy#>S5E68eMBoAx=G;QVhyVbS z=pMBRpVhN~xhSDcUDDusR+pcfb+>T*?BAbCzDG8EaWoF%f&MZc_Q4PK^6mfqkMX{T zazXNok1;?ttg7^D^qw_god;bw?yPyf=TBWyC5x5mn*A{>Z3Tcj;aGbUlt6-Yun+7ZJTnK`WZ){HV$PN6I4C3k&{z61vy@!R@UhT$4|~^@R$o+memm z)#3gX!ZqJ03SxdsLNX)^wEC*f*bCW8k~y|=)IzrIGOhD$QpY-PKAO%X1jr#kLEo25#gi-}4xJJH6V7;?BvPhf@{Mp zfr!MQzs>OqBE$aKdyGByhq?pWn)#Tfy@uK*Pi$}AKxZHGnS>oP@!%$A<6{5A;L!c` z(%qu!rxU$#`He8nW2AE(Ux;wKu8ytKqb_aVkC~}2bv}%7ld@EPdf4N7sA4garw9@< z!sv!^h8Nb9b|6rTgr5YX2T+GxjXkgF9IZVpN*~EdN8R{cJsZhyxlKVL1{p~xB#$vq zA1Y|%U*c7u`e^D%s=PC{Q2#b8dS2|v2RXqUvv6(EOQP{x zD@q)@khbkn)ZhqlN*5W~j{zNc-M@xZpD$sxZG{ekpz&Mwrb&8JR`bMJ4UU`n)^7kOX^P(|csp_gkU_Kh z*%Bb_A+}b)K6YZFypc&s)Wb@NnN3CXz{0B_P-9ak$jk`HhdawyBFPT2ro=Ruu1&qY zSE7AIdB?29gp=n>5Z^&3crJtGDOoJ;$anO#N>&@27c`$~%eDMsi~k0Vv+5bMn^AAu zgHni7&YDj0W=coNpTksUoI@R`TWkblgA4_$@O944T}iwsHokGYw_}_i+DKZGD5N$) zXKv44%Gi-IFC|cvFs(>p=e&UuPHdDeeWE2Jzv$0A3yp%?m5!Q^3I2aIL=bV#0P$gi z@{9_(pcRhK;svE)1CL&WuX?p&N^*tIRJ-lQn!kS$@DRnZO>a70UGOh7cXu7nqCmQy zquoPLjoH*Kx>}r!3Ih0qzRQ?|0^D22htdT~?c|e+4IMY}C{kNVUFwdC{|aY#toYSH znYW(4o46SdPmO|jZYD<1_Hn}^P?HYqi~Zzo2rrD6!0%VXM(mM4P9&V2)VCqR25X%( zf>9&W+7e^NTy#k^eHYqJr`i*b$|h9kUO3p?XSG~bP%oO@TIVBz7!d!egW1D zM+%zEoY_r^OuEHGswntb4iZo+_+JV1?R)Qm@YD`{*CC=IZpn4EvQZpudGFXaeqIr! zpCiCx{>5*+pRl=*TD3-a9L#|1_4}v!*0{ux&G+AFyDSvQCtsu!|LfY@jG%80rfonFm<0LVeVg z6O#ooas%7w9y$Uin+wHn=lSa=ygN|EO`LGIX5uVP7!^)gubz~q@|c!-!&yQ{LdKG& zb5&^Cmw$i}p;vQk=K8LW-jIlgzrcf&n$0h@UaoZHR#2_)a7|po5Pn>GprUxeP3{xo z@g6wn;U@$lK-&2YNMC5Z7z8C)OVr+qN<9c5b*QUmBaroQ1!bzb*?zmUY}19FLq%C7 z=xR0}(aM4YZo^7Q^vf=H{3-9rN1TUOQBOefhyE8@`}TXoY}`p6k70KlDLI~Hl3*HM z4R@exz^R2(|E{XjYamgIo!+PX@AAOsHIk<@??)N5HWZFaFbe%06#5Y1EnpH7@^jqQ z0`bkSNFMJu(T{}cIlO;Q)B9bGz&b0e3`e}$T)Vg*S_+i5O+CkZb{3xyV)!T=f^2{g zc{0F=*Srq;%|48Omg#{pcClcfTjkK%eZn!!(*CHwA3Z>gav)DU(l?I#Rd15|KGmd+ z{Tx4-pY5Nrg0|B^&tPf(zemU7*(KifpY6vvL(WFQ_T(_3+e}mm7%O!C2oj({JYpb@(7TPtahLsF{a&%4b3hRL*mpBQ)y$zFF`iWcip zsn~Si=HJj7lPPB8iZh`q4*E5;(jvqK`CKh-6bF8k4qV-coRxW|jVnfeRnc_z9_avC z!hjRnhDa!PBAW=IFByysQW9(`!M{##`gs4&?u}Ga8o~l{dL_MWo9`Z;{&-=r-IV4KQx9DrPXOrl&KsF z5NT-x@sN=Zv%UYile`K--3)i)Ua>L7AUk8wJ@T&K|h=-lwDgxy!(;67S{rz zy2)bqPese_UTbRLi0e(>S!L8414>jy>2 z;z1!lp9Ur;B&>f`NzFZLP&GSNiM7g6G(x>~PlW}eDb_X~EVuz7-=^mdq2`6y%bW0r z^nWbyt;Jyjm$I8XByfxzrsN3wb-ifpYOq-+#956_v??Ahh7$%xHdGNx2;z%_TPDzl z%C8Um%-01f`{h+&C##r5q;n%aQyIcxx4fMFk{|JT^Eg{BS1=S(m!Nn;^`FHDFInD3Ybt!6jJImyHJ27742`jU!BSJbiv-mJ!M56={)ykl z|F-r-2vNuFIt&82nVL453EEvqMD#t}z$+WeP1f*5MW*OZkM#&g=J63q6z*!zIA9~t zp4yjSQ>i2OA)zy$F9QEFFx0N=o=C)2AUyD+K3Fq6z)-`6rb+kSvE_mO(BRoUaC)w- z0{ZjX!XkE|+r@}*$pkA?Ljio(rP0vjTgwN$-1Z2StNz*YCm7%}kmR2Woe~EEj;P=T zL>)K1#RWqbj)bmp7jip9UUx{gyI;Z9E%(X% zVR{`G$yeZ|PW1F7ZNJ_-0CilvD+PC;7bOfbE(KO8qHfB6v$1NBwfb}uUd`K|;})&I z%THJqbM)?9n=xih3DHb&l>MLt9HbfED-f#*6fjTU&K>rYK_hUiNjD&U(-nI2(erZP z^8MO&m%`V9z{cZEOQFt#G{(sV({bi))}W4=)(E7VIYn$n=bA9qH-z z<~9D^M*JJe5)34x#tKtXonjeYl014Fhge4M;QUXKQqEdqss@4vcyj@ab0vl@Wn>ZZ zoH##78et)z@G7|ufrLnQegLTA{hgzCgoab=niPLgZFVl?H-G`D?P~wH2zI;6MPCMw zjQ zsrb)oneh++Io7DZfL(GXaXVoa4(TLNO#=N06>^7; zFlr#~O`tPFA~^Mb=ox<9i@miRTt-_8LxNg*WK>EQGOF?)j*%0ynEZ8@#5Y2)0LN{k zH5xfLDx*@81)J|oWAY!%ikuackG8s%6@j?QgiT}Zi?Pcpx(vf<=Xee=48Wrakb zE|_OBqM~3r{!#^$1%^PDWzw1ymjg27eX<=li9ouLdrd?zpLbLF;w*j$<_5uda7K8- z{_m%Qu!jLq3u-xoI;qBc)~!ShYqy5tilFVnPRhLRF8yC<7l%tnShqz`O-xO9nzR`Q zds`q(H+oyGdU{|5*bq3M^`K~3f&R(=Go2rbHir!MH;(cC6^JD3l;T0db?kJ1oYEm{ z{GWPPvwm@GOhQSRTUhHVA-4<;Z<0c1db}f0XitVrId^L65(hO>9#!`G=D*Cf8c>x7 zG8i!o({OkOUGeJK{6s+QNLT@7)X_4?;^>7VNK&VSF==Is)^Ea@#%_zOR>`ZIOR*cJ zh|?kehM40@@h1uX4{AIx!V)kr13!Z@OvM!BQ4=5 zFPU;y8{~>J=QJY3668)!YTQTS#?0{ZUqLo>gRk<{X#G!bRMsZi*#-KS?j~r^<3I&u zE=Qk4&yjBYW+l6Y4$D(Vol#NDP;@V{#8)oM*=L(hDW^#K{*SOjYrYTC7)hf*G1X?V zC)>4vMa&9vFC(+2&dvOP^{0=BPQ+?5A_`N|C74_}8YkYvpjva7PIr_Djtojbeffg> z+w5rwgE-aM{{fUhYrmj2S4HRs;9N=##<+aUm-6T>_CkdQ4&Iep>WvV-;lLSYU^ZT! zghcG5;X;Hk7b4Ig5mF_EDssN7s8q`RVvtj^=9tCsgFr^&)6;azG+?I;c(IGwxjA@_ z6dR&@o;!|XWb#=o8d?flPo190BZN=eXbd|D%b&(Z4KGYWB6h-;w?a99p%Dggc8pM2 z(1^&?*)$D#9~U7M5acCS#0dcyXNY2U!sU~XAVSWaQ)wF^3s9}k!D+jY{zHIEu^9~8 zz{uz#WbORI0g_W!>f!VTz}1gp(8LRqkcgeIbqz!Z&5{pyBtl3Cs8oo8z&1>z(iRL` z1(k1$m#jLIh?6&OcE`e&m8t8a8q28>a)9T0tG|q5RY^qWW4iBK5?F9h=Kdt|n zi%zbA+QtU5X}kytiP#CZ11)q09JB`+;uB?@G7ZnAvdHGsFj5+1z{Y8iuE-1LuO*Cg z`5sw_=JK^+Xei_}()C8Wj%Kw5x#BL}7vM3I()W+{VNw4wXv6?cYE3YY4_B{ZW=#kF z##^z>z-c5TVkb^-hWc)7Ex8CUb9Ja3P@S8VZFR6r!N*bkM9d5z9StE{CibBjf|1+O3k}k6L2uJ z&=_u@{$>~JQ}|&L60wsy@grz0cF?}chocbClbWWK6e{H&8{#2+W0)j&y&O zcLgOfl0ynhiF{u!!WD~I=!%BG3(%-F5c%DjarwJI9C8?ziB)S>qCY6pC^&hm>u?3q3SyH3Y5?-W40&T5~}JmaJTYwJXntq1xSaT%1%p zLWlRv3LLznoa64A(U{b?(n77GrQ(1!hh^ z*&XzS5CSKv&R(>Lg>)l3i#RfA8wgV=QYNA_O z3NR)Y(a<~Eiwn-X1m$$4n;nSfm6&$W)aNjB-8h=>xC>izcrBBNovN%Xb@(ipzYXsSf4MK<%^>h|g?z&3^1P8(}JU*g@lG3h-nyy0Dg$F=ysR-TB z&}_HRZnkA4?-r@Z$j#@)j$){Wf^*M17iTTq2$ksI^6Ny@reKIMgR#;y#@{&san5VD z8Z8k!;X2$nI*IXjS24MgBZdUz`C`4 zfJY~AZ0R_r-uo>q8+pyiors20VsLN zpzU+@cY_mX3^zD zCs37WSTnp57hkj{H4rBcbw8_fl09iC|US60fBC*P;>=4(NHNB5VHV{ zdJ};gNU=rtBfA^o5RA*zzJIhATduwas|MDA66LhB+C+*zhl$KMj^26*(>J~gZ_MM> zP9k=~_HA!ZKZw2WoWRi!wo%V7h_P__{7_~Pmux&2>n>Q1OvQewm2io4xhpIo*fd`0zN6{q{k4Z+JJ}kookhb$>e%JK>$Uq4WbB_@zl4`HeQ}WxN2klEOJF z&%_0norS&?MY(S+nD`Md;d`O0et7qL&%a`qQUWWj!?bnevst7vHk!>QJSjGV;8N~x zAGC$}c^F2pawaam;$ob?=2DnHl#&zq7{SLBjxHU;*l!&`eEorKZ!qy{BN02{t$6Fe z6FBnzqd54xP1O1qwx$+PpkUR&GF-FeLae!9F|3>}=eaKlkpg$4^7BuGusD|Is)aa3 z`V*d00yAyE%oxaKQt0i;!*M!jbyvo@l={TrQfdee&V*bgA6znk>#zTLoV92ZRHB}g zo1Bc?fQt?$ab(Rg9J%cW2;Q<4TZ(uUk%*l%C|vwB_Ws5g4t=nJ*r$8!_>c5NY;;tfxlnD30Y=P+gpOBA@HFid4>oZJX%rDS(LxwYfUt zAYO>nLOw1G$RnL#gkkNbHMsHhZ^4S*GeJQiPFp3P$3q9>IJjy8M{hfd_?E4>eh{xz z60sBJYo_PBp&)Zf>ETKTXQ63P4uN;>t}I;o3J{fXa#@1Q9Yi{wN5D z7q~=#oD_2*oD=z)Lx2cC(G;W$7Oa$xVj&H~R8gI&%LO#~b23uP$5Wq6*@G|w(FrzP zb}oK?%S{-`F9kWZgjK3eq~-xUOyKbF6vlqzDEvRV9&cK;70~fYBN00}f_ZS;mG%#y zym2oMeC{arzq5sU3NJ!N&EU%Q7vU}Myar1)4FjqGj|F~YPLz^@Jill~ioNj0g7!kD z+MqzXXdzRukiOa6L46napEBdn|3z>n0 zf;?aD;usFyG=`Z!o57aX-HhvPyb?&nPNFmb+;{Jmm9yyFdKicQY>XT_+eOECu}Etx z7B-)G9^UnyH)8!2E1;PQ;)wlNQiCtZdpg~edVvMeC5Ek|(3e3jpF(dX3y=tNQ&qWd zEr@^+y!%gx=YPhD!j&LIrkuvrufG;=xb!9rWEV-5d3Q2@YFZv4z!Z)e<2Z25G-f~X zH2hz<3ReyMOxKntVkd0ewhyC<@ML#`b*x7{_LofTFQOeoSPxS9H!_P4sMvi#K&gPx$V=qX3hKw zx1avVXM&1zXROc!2fR&u@mNdeLaVz%f5=oesvmS*E(n~=3p--v>cXV_ipC z{yglQ4RRt<1JA2!15OCu?U6<~N<&3l`^M{GTWQ>V|2MFA<~ewA8vtEc#qTN}!b264 zY76x%Y-E;q(DRMW*!-945ghn99`^A6gGB6v`F+_RpmpvPcK??uW;X?J^_NPS=P?xv z7p>ZaTW@_EF1}$s6pJ8`w{YMmLrA+$v_pCU#DeDJg*%xN3R!eg=+B~E??|zmbF-*T zmE;_pDD-)Ab+KUhLR#J}VhMSKbZ;7$Ubh*!d=cNf_uJTZ;0I_$H7p=bdBzdKogX7U zI`8q|UQkEnvp3+1FW!moRp-<5|7Xn`60wtxC-(CH{Xc!uUQ9b5QKgV2>j#-NC+V>b)0iS?h_)C1;4lupc+N4l16SOjmE5tcB6)w zV>8H?bFd4RRB=Jm`k4S^T1916*>pbl;d}YB zzxfR7Z(EAPtD>deIjBv`6i^GWmE02JVyH-1F1XHQqxuUxluaw9s4R%0#_4Wvqr0sS zJ!OD}O)<4eD>jkE=Ak1Br<#u2?_$o1+1z%=9o&BTJuI^NjT5&F(EH)IGE|{kIun^&b+dBrVm|fJ z4|3Bz>nW-Z3Ex^v8Gz)z?+Dl!h@s@p!7~R&vYsau@^E>GiiQ%|ncdOaPwVj}3X4M& zlof!54Qe~twn;GKa&SX8L{_P8em%j$0xGL(*}U}y_P1_lBs~C-&wG2iNGyyH{M!ie zwSP#-kFVKv+iUQ+#yho(z`kg2iv>K(`~`2a_xe7%?j53UL6W#){`a74*6s)kTB28aRSJuecQ6e?&&r)L#VFd>M)mVxRm0uV#?pFWXIvx z=@@CjK({|jhMB||DNK>PH%?^bVdcP=wQwpy_BMeQkYmK^l`|%aKR;0U00>a2vWITTJGz zn8U5N-^mBB{RoR|SKx8@n8xzAxwh`{OV0#Xq^1=}T+SOikKZ=g;hwx#D{j zH5*7X@3ZXURxYzZJZzxYPt~ka%4-57B7HO-JV9(In)x>4heYfd?y*U}Asalw0Tm79 zZ1}*{eE9ZHvUdJ86naWfP?*S4#0vZ9xTcksuOFi8Kb|JI8=1SJ0n1?#k0k*Mh4XKgX^Sgl zd^QE_!7sZauBQz-AF@Tj_Q?*9LUC1?s)iBr@mEL^>WaA`5MZ#3}c-j`|VZ$z`w^ok2ulCzd`YLzQD$v8`zY%2aJor=HevQiOnCWe2UI%yJ)>{ zklwX%M*Nm;O$2CJmbYNqEIxS4_1t>r2Ij7q4o-`t?7%X)FsNo!hFFwURJRJ_z+@v# z4!fmgffT=VS=fgoZ#)i8r-Qs@q@=ur>GKkK$x5b6G(Wuu#@@xB!?6|ZK+lxEoqJU+{1%e4p1&7B;aa92`7uQi% z7NEKDI7jy%Lr?2tX{C06U^CQ^1F>;!GSde3sE`9%I*oxr)wC+Ez2Qba`oZ7g>SZ@m z5~x5$Wg;`s7^1JHgO=YrLG$-tBzWUTxUAqJuzy48pl=gRIyOI0xQ*f4_w&|Yo#4<% z`<0&QDLsjTYJ0sbAo08XtY5r}|L@CR;Lrd1vs`h@O56obA)6R!9ffz(6aj;wnc{sF zB^Ru*SM}I@aw&=-A_*oalOwk&4sZvYRL?47?!tPUs?PphyXZXKDPY5pts|22L*|Gl zCNNI+T$pYsqn~li`)Qg+DxH$~^aaZovGK0^*?8S2m{+qLufxw|EJZTdt8`v@n&z*) z#n6|oWnH~3hH)_z|BVpnAKCRU?x#2~vyIP};K9@E-?^8P(h{6rCm_6aFaa1lBm!V&jC$HRc?NG?0TZ;8hT$*> zmxsCH>Z_=!s^^VuudwU*8}vteFfk_J?-)cGs@2i{JW6!t5>`AM*fn$OgBvzk4{k=k z2<-PUsE_aZgYaQ?uRFoP`?@*3Ax5OkGR%ovSiUfqEm_9hcizA?x2&eJp_sIxlMtal z1W@mZDIiR)w1FD(kk2d|I4GkY+vO0g@=;O33C*(GUoMZ6^7N7y;>7*EbS_Gf z@Thj!oek8Yk=;;P$DKD^$36GmAVd>~S0#}WjyC}B8K&ipL5pi=IM`~QVzK?u*y5SA znb4E^Szl08hbnX1b@M8j+fv6HuODXTTiYqGETgot6deQ08t;)a!EUoxauZoB`!Ttp z$@gnfSXspStFNZMb{bo^zs8QETj-5;PUyXLGzRHelp_8&aT2RnvgSW_vGu@3V84&- zfBA~{6;ksL^49&WG~P9=^w(=vdbIeG6VxG3fXfyw=e`f!!gaT;rl_VsXpred8pQGN z9-1v}l#ZuyxKvc9nlHf~CqHb5@rg(h)#ISR-kUv1B~(k^x3D6_^m(}2|v**^)-qFXtox7>6t7pdi8CZa6BQk4`-2|iqZjH~o?fyBY zG8(wUn8!dD?`;w;3$t#+Ra95ivUS^LcAt2Q{zMNJCizS!^{-s_nYP_4}l*JjTI4YU22FGh+P|;fRlOTFT?F=fZ4a`+*-TmK>+JtnFTKAPVW`S7O;l7&%pBjmleyDo@Zmdd zVdMSRQ#+>u-PB0L(^#f}h$&@?9B4@$EuJP+5t1-mJf4zowXTJ6Wc0Aj?*RGchoYBo3F8*^A~W9Q)`96G$0`f1Z>nBM?r zG)b{3)EON=V$$0>Ful|fWjE08G?_l1N1r!w(QM#j&w+_ETZ?r9M z+M%N}MBicyvp$VhbqiPjWA(O~qTX6jNDQXHLbi6GFoCyLY>#czI^~eP*dY%t zkBgD+h=lhJujA~2tss+SZoiYVx&&3(XxTX*_;{in>ZXK@S+ZfP1_^gqU4W z8|!^DifDEoK2B3Dvvp?4BbaHS+)`0r$<=`y2nP#!ecubT4K$KR2}hngM1MF*@>3dV zkG^Ze-#-X#53*Um7>ZxmUDL&naPsbETK=_9>0YW?=`rhL#+%3CVfDh5{P`b#me2nF zMr!7kkw_&mGy(NgHxVk-6KUcDalB!#gum8V&_N9o@$T4gz#Z`53weq5$A}Ncg;l1Y zoHej80U|%p$-&nSb zL!UTJ)87u#Gsm|CpS*@pg- z$B&U1iKC!mS&9u@dlYPXz2v4!9@ht(7r*t!+R_o^1&j10?}K zVW548>ZR3EEjLW#EO&AP4i$&TDPFs7Miq4@s@U6njN_+{Fl+W4Y65j~o?)2Tb80gS zOU<$}&z*ppYUR>d130soP<1IZ%$kPBeG@*9pY2CqV<4HoC6*%L>{YsNH!Ra@GUvfB zF>TLOmOggApDN#fOQ5!yrr++S{Zmm!{8A)?977xm)rA#&eVJgkZ`*g+_m76uJl|-$RFm-gD=rJB3nUogWF0lp!VEgnx@-g&fk854R8K{&B=?veh2nC zv*tB%^6xrm*^nga&sX_YQJGObjZfZtHy``lt&~kKB#}ti%ACAEKn>8ox1Alo+Jm>q z%lzx+;w$vZ+MG$59roHb4lMpcpKSf;Zt5e}A0t>E;H=HEheO;xH$~+^8m3oKbG(wn z9Va<{>JZbWPp4*fosdW@(-?jHwDm2`XbGZWC(xVsI*5O5cj7qe#V75D=| zUfcByt%E1>71cz4Z9wh5$}o+9&dk4gfR!(^a+fyM76v-s4;wf(Q8BNT=6~v-`Pw8g zCj#p1eQFg4i|gj{z$ZS$ogcf3!s;NgSX^2@Qy;bd0} zE#0SRKYg0AnsV9WH8#W^FMgOEv|s~&EJbyaV1|qZg1T;?D5ew;m#tWd%jx3vozHTz z?{HrEW6jhUQu85p1YSCpO`cWrnRHftBF;uF{-;Ml-Tm$cFOckMLY5F_FNMZO?Y7|)}6 zSu^hv{_yuc#qFQifWO2;I;BaAM|q#*(+UdFjwr{TJ;sh-@4*pra><>`sGMDishek~ zrzQn5Y!I2)W{r&yLS-S!XOuD6K1lP97PP1)pXc02G~o8R2p0LMsx76yqDt6c%^fG` zZ0kUneJEqwRCeDvgJov_b4<9NO+(E-UCtT54b(nue12ky5{)To#Joidxpwuf%&%I3 zTlJC8G|~(xy;nBT_?71|*8Uf>JWLT&n4)$AcP?$B>F?S&c|)9tGv5XdpTo};3s>{m z&u`@FyI11$J7gpgYw8!tgqG=41h_+|hB)zRBS*J25}sDTnh&j_YIcS2yzK>klP9!D zUmEz&|9*()o_mu&{QXZdd)73P>6AnUVhWO`bRFwv+ao(fBYn+1t0drcF zVlP_&`-X!2%uLPVR`UKQp{L;Owy>$tyTOkokqcFumZAr07K&{7h`i}H+@P#9sI@nCv z+;T3z_cE$xRiNwoIZ<6LEaZ8r3Mkm#D}$RYpP{I_kn)-3oZQ|-`+;@}stfQId!;>i z)(R_51&`lJu*griC_u44Ow(XHZJkXF^eiA44x%{haNI^>Mi`wH*m7jDOvld5XPE-C zZ`x!S#loINw?H=yX#-B5Jp+^*PN_u?xV~6qeFcQ?&_=$XqI0BL4-EPH5g<{Wd? zQ5_EIXEtz!d4tqyj&$rIZ6$Jpo0PMM&f7qZ-Ne;j`2;Vwy;u9goKs04;W+VoJ81jd zAbkSrTp_plzpD`C)au#+86?BV#nlZ2|nTz$tH z=C7NL!>tOafpZ5nb|Fep?BxP>ttqRFDZa2*!v7O{PtkI`o$9$&c#3>79AfF_SyuRw zN^?S6to5C?Hiz@x0U|xei1!%xYRtkvz;Sno?=1H3&B`-U_0{6Jl=kf?0dxx zLzeYZH@%*<+STZ~#_^uLXjY0`rjc~_)4A~|u9l~{YIZrMiiExAN9+*s(oTD94Qn(O&n{{pK&zTlNm+dfZO1xj*?*dFO(CKB5U7e2 z*-$1+GgWYVoP>%4lvNZF_J?t+o%BXJ>FjEwZblsiB|$Jvur<+h?EV@D=p2AI0fmf$ zS*}n&!`Ww3NkunBs<~n2Obp{X%$+(XdJmvm>3l1O{M~fkzYX8X`?>znhj>2re+k&4 z_kD2Jjp`euZtAAvPrGTW*GVG=Sv~dA`h|S{x9;VJ53j)$Qc1?N_XXcuRTX-U_ONx+ zHuk)Jh)`*eYd*GtWmhl6U*boXMfB!*6l9Pv9;7m^*b`CNaV>=>Q?UqFhnT-=4yR9c za%5j46%A#0i#)hP9tlS$FQ25~a0*$4irP|&Lj{D~0s2xSobEcs?7q1K3jGME<#p|9 zH5pay+$yKG6*KwdeRpxgJ*#mC9VFxFsqLjz zMWlF*q`{HbkFxovZ_?G;%YwD@SbfWK>K50E_E=ie;r#CWvU%dA@m_?{+88%l7^aiciIYft_II26%DE3OC;E({X#2Ixx<(mm8cUr!(9wH5O7 zG3^k042ZKj-;Bq`o@bFQTnDnX#WMZ@CRWDz$mpk#ZcB*`Brv6zlT6L$PJ&onk82+AXFB_#FTvcJe$oC7i7yZS}?YiLvuaPvjiCk`}mXx}mF=2Q`?4dV9joU|0q&uba8^7LZIPNF7Yl6FZ$NiV@I9okMFn z_0xZP1M7cp+RE4agj4>T%sku34aF_Ae!83X`yvdghE}6ZaLU(E2qHRh~ zF-Y&VC+NI)aMuRUZ?wnW$QoZO#@&7Nd~t|@V7@=Luq(_RS6s(OAGn!{Sw*tha&#y3 zlrhVe^!0e%473mO`Y*P!^Xa_=gMM!Q%(X1qFdu(uKv-m2O1t16$ac{diX1jjOv}o* z@Lqntz7jvnuUW{cqb(dgc#0Ws9jCaV2xrJW>3MAnMKsa80XHR8g@i&uf_(u7(*1Og zwA0_$M`=~5Xrp5akjGAOnINwspvvZ*Gj0G1)_5r;6MI=;M&&#sw9gz3HSH*Jw9XJL z46dgwIsFPY8S4_+oY ztK~viWLOuPqAFfjz>6x#KBGl$$szZcfRcMrzGYbgf{IyXtiFCZr%$!BW6NGX+AHIgyYPi+914QNiy@CuRaecW zi`P+KG7pF10?3nZ4`0zv-@QNFw65Sc#be9Ll*F#<-6x3O+e^na5e6KYQ(AVrW@c#v z_kHMA*4(rNhgV1&C{t{B!{v4g02+5T@!~_Ta$?6Rrp>Hj-Cb*#w_y&RkQ-fl_v-<6 zew~fSp-ehzFmyu-X{)YZ!QlfZc=h#d%v{>QWw%};>^WVVpem4*1dK76W= zVKYMSNEZVG160(O%Un3tBnn{y{s{|VHjuN0?%CU4nI~o|xY;I-#^F?@#~qEXV`T3j z-Lcl3^R7uf!4M<&9i#8H2U+mkgB(u3?}2^oF3j)Zzha2fpB$j4#KO!;LMiqYaqBhL zaP6I!;tRW`R0Z6^!s&A2^>`TRjj-*RT|EDz%|v>mTyg7aR^NFkRr4yqiEzE&i_dMD zmOVKp6|b=)k&`qDw@uZedTu3`ty|8|e)c#oKlcW6E}2DXLn*qRm^@j<@204%fMCdn ztJe)!3?&8_9OxsNNDHtf$2KQ!Q)S8&+mB~Mq?HZK*)9k$DL~tIS#oi6x?IeeJD=Wx zP9nz#^XA(zMyf~fe{3_e55VE(_jM@#;0Bz(c-0W>-C>T6c4Xuf63Z`bhH=xEE z{jB0mF2Q^_x?hd(GymmedX3b{r{=;m0We=E^{6PySCZ(+6ps*}NVMzfV z)q@KcvGg!~1KmVNVgeNG155x3rN>6$c4&=hGUjox#umIrAJ6d6@}M2MGxohrjUp=kKMX?t*@Q*hkEJSI7FW_ z7Y|%TWm&^QK63v}%wIhX!;(2Z6G$)CN5 zE2VPV$8O;I`!`TFt3-Oz>9~$%y?5Gb3PR76!{KyhiHR+2hhq<40Oy~Hl5|W z7V^@bt!#O5JNHb#P0|uQrGqlj&(V^VLd9hTcs*{MIAyYHI6lbG;DB5Z-2o5gXaifq zax+(mZJS(p#=n=HlGvcnfMnLOP;<;}#sS(K2W$Hz-O_P6olI+(&Z2=!7(Up?NV-2K zgJ47*t{pT79?)sn0f+nFcfh`%C4s{Xtm~y?Zi;x$Uyw)faOKL&xZ#eq1k3%TrUs~G zah<9w?0xMZ4}bTUYZs=W}1F_mLJ8v{9CMi^|S2K-d8zxGo^F8>HO3P1EGALWAJLR-fNz>Kj=e`>OArA^Zdo1{wvQtvzaxkR`3UZ|2eMz*ky>V=Wots$`-8=E)*4aJ5=Gud9n3R;3v1HCA6!|O2Z3?07L$O_%>rO5L`MShA)!?;uz4pek2#ZYvBk)dIke`c)4 zA(*2@vOI+_B)72F^=9rMM7+G5mN`9Un)V@Y#JEGAC+BCCW-={ocALy_n@pQFi@9}6 zaXY*@U~Q4s(ZUFBIAI~p27)`}qG3@zi)PQ^m4n-P^YuNMy^>w|~5ehN>$5?2kUn zM}GH%l-8Ayil?R=sO^e4;!UN(_;}@ z_?JyXmk-dlE=|&z!&{5g?dqk=x%TGOc*7pzvBY~ez~S+EaF`0OKlv8_{$D@jjh%Z~ zy>tnG^!uM;?TxFz1&LVFu6j>3JF`(zfURbDY`cA}d_Wd14Q5y}pN;bEn~OIOOqs&v$v86qSYu27NGuKs#+D=yiBz0wYc%Yf|b)_n}A|d7ld)>c6*pReF-g{jT~;S58{fFwJ-0OIr%~ z0zMQCp53&Wzy61B@YbGv+;rtS{_-oo&Dxt+VW_6)lnVe9rlg4mxM4y@el7nV^>%Vw zafpujxhbGpmh|h3rxh`8_6%H>i>=%D($&@{5l`O2lWh1@2^9ti6a+9O$5+K`4MqDH z80y71qo1DPnX_`oGy2!|-L}+nCa7~P4j|JjXUZd6WR7}{$`~`2o3H6<3X6)FKYbYm z-jbYzBH@t}Su;T7vZvYLnvx#-fn6AD*t0gq@Qo?rIbYx7^ssK_rCfRaC8!=1Et-0F z3uYD|p!SlEYCQVTllccY#`xJ?>Cv0Kk#3RdPUHYQo#JTq0WJ^O^EZz~(Ub=-j2 z_;C~6Fl4uRL)~nqpPtYD<^c@!tX?~&L;@pLv?-N*-GVJwC<~Umv|!#2A@p zVdZRZ&8Vp7_FJ!}a%K^!_kyjZBBYb}K#c$W#v}agzkHjn-abD5!Q1$gzxX_}SIi`> zO?gn;9@((aW$!njHg;N^e?AYA7x{XU?v5rb-VxHWbH586+4CEyE-2-|!4n)g zcoLVxDZ-6dx%Y{A{4NR#gSb`CsEmSmvXRIju~-DKi(6tgz->WBy9H%8wQMkEzbM#` zWdm&nc+K(mnd60AMqzEgpMBU;Qx+yw<#p7T&B_?Uf@r)@IiVtpD zsYV%`9VIf?#3Yy1l~;1jwQDG=E3`vG<=q2yAQ)hzFUt47zKL)B@DTyUr#^fqzx6vG z5>?JbJSjyW6sEo@i0Mfk0Efo`ki8@>H)SG;=wM8ys{HslH?wdD-Bi!1VqWbuo<6jd z?K}2!$>oc2xSg1$mYbsMbx~Lp!sqcalmY<-CL^&CBGF+1hJqbBXAvOG9WG}Lw{0Mk zi({SvHc-az0?RC(*`7TI(`7Uy4+MhDsF_bo&k6eD9a%p|(o7O%WMPuzZC~QXQ(xnm z)RX}Bk1)$3jNBGuI6!t20fm)|7PET8ViYHsDRY#vdjZY<#f*CD@BH`A_@D3nR0>YN z^^v=I;CJt%q_#*Fbdi6`J@)D#Tofb}@X;}HM%_d)h$lxF8XlBB zu=M54j6K@f$1-z*dHii_!alSDkRj5NL;Bd76m?bFqb`Sox~dt}x6NYsbT7Jn;j=Sz zOk#`_4>NdK7exg??|TmH2U*~Li1geT@oVx)vQ`$CbJZ2AsGL?RRYSy!ygLSS zqM>>oO+7~#O7xEL>!Xv7N#c$eBP-h}yyg+uB(1Oa64+bt`3y!rlybz%bKzRW!Q$ET zxNQAmRF8rcx8Ak3me=Pc71#L54<6@h-}#9Q9enCTckr1neOQPm@mL(J_YtTqP#ogb zNDe0DwkzO9s>kEKwW`_?k*eVGN`Z?=PZUGbr4WX}*_r^?>2*>utDL!Y(>S=di9H97 za&6yQLM1`r(Pybq$Y%`%eG~)&D2Uh@CK@It!_h&KsU%*%PZ(&}u*r(&%rJOjFk@wU zbZbmVZ)WLr=dP5SHu`f$4u)yUQvIs(2Fk;Aj3fsztxTZkq=_(GT}7X3|>Oca=?JAW?hRqRXI1eV z!81&1ZIqGvTp^u`%G~;ytXjJeZ^$W%o);d}2-VSHDLnt!tNi!ZAEv9Pk6W%>$LD|N zBUH_H~Y(lKkSn{JJKHmly}5-^AvihnOjAD zQ8_(5{p>$@oOD7HCR)}=14}C7;ew!4(QQ;fEIvdmekMi80#A!^fVM4Hiz?%OyYDPt z8N?R*DdQlQai0Br+p-W_Z0TaT7}r)zrzBVbC|O!xkS0Nl;iXa1>o;uP_}&5AWKAGJ zM!iGyO;kSS1qs&}7 zjYJ|Y46OH+S+W3|KxDsaMm%o&!48gWJ|>lLr_VV#nIbIN=ou*X%ed)a`w&>?DCw*q z7e$B5N!g4trq|X$%HZIk6AbhY;c_|2Wp0m)P$+=g>B6*wS>jrZSZrimS0b}bB}NuL zxD7%8lq{az$^hwD$s^>x?0~j1H-TmXI#X6j%oI5#gK)Tn>Y^FAR8J1CZ4hNRlpwb1 zUK|x~ON+fHId(tVv|)y38>XJKz*AdV&FZyFD5?lb7=D3)EyWw3&&$58hxzxf{($ZK z4zhaDVt)S*KE;yt3rMEZ7*oAZohb?^pjKTf%{y9n{U=*xpLJ19;phNJ_HjuRI~2Dh z=av;iZl8<()_#n%jcE1Q?jnbwapv_@#tgnxSjjT{V29nQC5$i2~^WYWUkJg z-Loc7E6u)%fr-cKrM6-wjhzP>NOsr-G;FQ+I8il9QYE0%nRzN6;+YEy6r?8|Mj&W^730dm|I`Z@Ba4vthxRY zVL9pP_YtVYkU4RHXcS z!<0OmR4RqX=?9EF?Wr6Z<5|Zr1%O#FoLh=G-`$y8Lvl+S+o#ymcjYD3RD^37N_J-* z4$-8DHdHfAbjDw?;7vH3epi7VqoFv-ft3b&fE*U|23Wpy9_4j~!Xw7Iu#;MDw~ORR zf**bFF`j<@bxMK-eEOpwlDv=@VTRGeRL;fvx>RmqiZOv zERY#?m=M>Pmc8P~?RQgJU(D%4Z8Ys_p}4LH#R1m2RL6A-mtUo9S}Ao^73}Ld!I2}U z80Z_OVp@f$te{{UGF2w+N1s<}1K990i6n+erIIq7V~pMKVTacMc^2I>2F!T?Z2@G? zVCP@Bp9joVw$y)Ygaf(|Rw|3@Y3V;sTpu1wjIq78KSlC}{mgsm!3~GoAKZ-ot^nJ5 z5Ow8)gKJWxmh0pW!|RJ`S+abNh}os}3#VmfVTnAjYAHPa(-(Mn)00x%x$lmf*m(cV zBBL9RB?Kt%BQGuaXtXEF?#K4B_r-(ME~#bRM^;l@QzW1s?L}wWc(nn$|ExGvO6p4p zm4s;BbDHVP>M5I5iZ!Z4at@JFVO5xhX*C4)dpUlpnbWPE)J&_C2nuUFu)!z5_Mu82 zdlZx=@kE3~GEUek#=`}TtmI3f(piZN0Ga&e(EP|y08RUqMBxv5=LOPljHrZL5h0s*J#a`-X*HRuVqNlH)*q-DWC9COL=<~6Q&4cM>J9*mQkVPg8pVIg+`OP0;1 zq`E+Iu?tc(6JZZU!n&rzt$h7|e!_thCs;OTHlO*edzi6k8u4V(UL-eV&Grs1p9=+r zlUtg2>OY>RaZeM=Zd$^X_pc#bS%8+BywtHGhmcgpuFLf}sF+z!L0O2_gKZ3)>_TR8#=@zp$^gIPpXbi9~E% zU^1FYm~vQ_IabTeGIOY&$oXJpYe3r|HSH=n*otZPIi_7XAK&OQ?KcBifM|{#3ISm3 zjxoPKKv`ieUPmBjbv#Zqq@!KeMq%(>%dt-}JSRnJrH-bOX#%$T(i-M3nt?ayx=>SG za`p3gJwylM!Yq6Bt({aAm-5Mv+{vXImXXrZ(l?*dA-H%V$vq|iO%5g5^VEK}{c5Lt ze``Owh9%c7#2s|W6qlGXdE{A27P%n>!j)kv=Tyip@21@?6i+M0UF?zl-B@S;`HO0b zsI98R)qI5GjZJiS_7N^AkZ>Fmuo-Y7%;5KXQB?;RHt^F%l6WF2G)x={CMF7J0GxGb z!aH1CQ$0<#xGpQikp_;4!9)b zJ&#hwGNDyDs~m^l$>Cim=sePerHj1M*#R3#v!sny+fYT=<)^*7ms5>xn5Mj5hL;zj zjNgY-9V=g~8yc}hMC65R5HXSE55vcX>v=5KV$ba2XJZ?L`JR*gcU}}Kq1a!Jii2#H z7P?BjE=sKY?Rks~>9IxrX!EWO9*v#`jdbp6xI4hSdDAJbD3DOd&Iixybdv}s9Nu}1 zA3pRLP3`Smv3v#hf8q{`s*6Y@l6J9fihAnN_MDc`IkM##uROek&X#U2xo# zLkTA4I|g;8%a#a1s^j(ZYuWzdUJh+NPU-Yg{FQ;T{+XGYi8tt_YFasE!9seYtsFnz z%!c@7680NsBDPUrxZEl}pGSag8^WQErJ`tBT6paiHp+4gaoAvicT&~1&(B{7%Pi87 zv$}3SH>1C8;`0Y6E~v!S?ckZRKu!N6&HHm4Y8b zJ;S`QX*+K|wjFQC%dMZgfyGzN#~pB^8#?dIl$`5xO$)q5K4vYS&e7eC9Nd0{>5J=` zS29N`@@K)!lU!DHJ1MO#p}e?=!-K7yXl!D5C_-sfF=Olu*~aVj$npT2q#^A%lBoom zrXvI!Th=Ihn`#0;3vee<)rNe49v56&6NA~lnf-oKqD>b?p)$NqUshm)MVy!?iFWN_ zypIFP3kGa9LeC~G94B6{8ELYaf=Yc=4Gpuaad;ibj4jG}AA)c>+`RC@8~oyl=Sk@r zw_kr1H{5Zhu(ML}^ppiODtLTuaRJA+p5Ud2U!&A)t6^k#M$8$g5%#IiLNKvh1Rtc6a&pMkeB$8)WS5@F@a&WTwG+iCN zlvR~TbYQpAkP5ul>lU?I+Z6ds$yAJVI%O-5+JGh>8tqU!&pBrP@5n~GoSQYGRv>3C zpXr^mi`giqB^R~AU@0L_F(avd>`!LDM_Nfb(pI7_L0J$Oz97JUKvYOKI(53|CW)6C z+1<71b5C!mqr9dVMFsPG&pBJ*^LuG)?BHiVdy3;Ntz5cb0U!S8t&~(35s60as__&Y zw{&~mM0+E=^~6rzcx(qwhl3kGbq&j}TP$rT!?G~WZNGN@R(762j>T6j;Q0Pi9Ncr9 z8E;JE^6J&7UKRZ;bl(iaB2*fry1s(Y4j;XPLo_$FvtZe5St_6zqpu~3rQ)$&4i~n` zY&MBhT-ax_0VH!fs4ZrwkVlK&?tx>1nVlD!*#o!6g3HV`$8F0E=K*n!e+wQ!0fPPl z3jL*YMw&1zE#rLMN|Pc{nLsZA+Aj#O-<}P~kEsIypBb82J8B-6`ilRut2-Bi@Pb)9}^i}cJGZ!{+ z^+&E?`pRjjZUs#{&uzicne1G1j27a_OJ@{w>9xx^b)uCm&u^z;=`^OTsK=OmZZAU) zo}inGx-yD7<43T^2`Y{q^~^QmnRl~@jd5%N z2A7M%U4k_c#|7Jh;T?*#;Q!kZ*nV<}cnV3&rAsGe3PWdHN&d{8Xe@01vh z^Xp%}$hN%)sV*&L<6YM?Yf%Hqq!i4UGNvGvUAN0c%l=mW_wRnjkN$BJdQ9ieFWkh< zU%Za8Ib{MYQ!~N3F!D(N)+E?tfpR~WUbCF)s!BF*-paupM{zrxl7~-v2nU7Y>LM!3 zN(EreE$#Gl4xlKiE$RSr+wFGA-1hi9O|#PCwZ$W&z+_rgR-SH`LIyR>%CaU%$w22> zU&AwBFu$h~QHoHY2)`?o^9czOt~iOZZsxl$Sn(`@vhyck8Gq#wsM|NU~i@0VN$U;MJhygli~TQ>I#B| zekq)tH-d6U?O*Wv z=55Taoz6$Ucqdogvkq^ukCZITH}gj|Bdt%I#4uPAXw@_Mtx{=N(pb0^VWH?2lNI-qQJ$8%+ zr%rUfhUOuM`CL9~s><>D-E#FoIWM8DNW7Pz z9&N2j3rn7i>{a_J^f@fX{RUeE^6hN1TdxjHFw1< z=DjtOeFu-Sm;Z(ioWVHf+kn)NmoNfoHw08blMNTItPBiiA>pO@g65Md*S}t3= z6q)uh%=gyIIaC>Fhz}%r^r5HuukZYr!Jd9TblXjQ`gcD}{o-15L$^sY%DcTSL^*@& zF%mpEfvvb5l+P?>*^+r2Y&phjo42w0iWL-=6d=Pqc8IF$DS0moB?CzrVhWQ4bDldgQ|w6sIzP=dt7LIT+e;N{bP$iz zM@$QE2YR{S??LB#ft?4etKH^_@96IA%FYt-x7}cZ~xZ)eEF|G!^{=a zWI3CjHr~^&n>i_uZDYb;=4al@+03qKVE_K3?Ad-$rkNa${A#F{d>Mp_0+baM;l@Ec zk)*e4K$aOO>gWZ=LOiz9krkA>p|=Z-JmWF!n^`4Dt!$tk{~npMADN5w zvn&BLt&FAnStBSJ@kGJv@#Av^b4H$NG?z}Qpo4_-ZKBwD_1L}isXAI=zKVEZQ32r+ z$)&(LFYa3S)J>;ac=_eG7#JDh=But??G-CQ0U0iMPr&jvs8xJkk5s$=`(Gd8=TALH zRcR@|^SO_3=SOeEU+gCqOT1^Cbp=~fTU44kwoP^#wiXtI9qCq~UIz75yT9d<)FVpTd z=MJ~eDR(x>Zx#~|pa7V87ZyyQ%3@i#T^@Yy0J+RSQ#G{GL3{-ZL-d_jU@JJC8fm`; zIVc*-?ZOugAmDy^Z|q zKYyQJKl3uP8|wMJ&wq?t@4Z$wbHt-@d#dd{Fw53FLr-e5+DGxL6JK;qscsW23ovKV z463)6vv1!~P8@FL()CNF_iSbz!yWZnrjSpH%Zu>4d<>;y^!5*;Yq}6J48TIvWF^03 zTre&^-_S604SRd#WW#L>;A|_~lPiQ}4u|B7+Lg@WU7l*b420lu`RoPYnH&z)vaGrY zYTbe|de3Vp?!x0qk@Auquu|14Dk#7g^h%y|UI65BF)|qAjn{Y6)!#4Rx%~3wqOEP{ z#(M^8kK4oEt%vyAzx^h^dgdjT%$d!f{^0}McHi|l{VtO6B$&Kspa$!V$5sK*Q@R|M zjgWjM;B*F@)GerC!Hii94@BAW`VNxulz=m9j&ApiWe}&Zw1AM;Cv3ppz5!B+w19c^ zYNM)({98c*7?c0&rY@0#WdbPJ8#*le7!!Hh=pTR6wC}SOSF$m}axqP8l@wdi#ms1G zXY|Rlsi&v^z;u9i^PQNq6n}RX?dxNof}|R#?g(@P&^Zaa%+;6 z?M$k-bxwsaEGwtiS2Dxw`~rt#-a~*dJ#3HDCypT7c?Je)G=GYu=X|!iL_@q8*8&C0 zpF3=JQ4k797$#FWmV%MgF}0dajLC@l7cWRmM$b*S|DD)0+e?d)PA3zgFBD# zPyh67UV39YE9TAP|NZd;tiO4+XlQH6^pt2$D=1^aMPUINB6tDLNw4q`b=PV0>X=?! z%l6JA?Advcxr?Vumm;0k1f=8NchnOF3Va0oJ}h7)79$!>AU;{KP!MGmIWj8)rmUJZ z*Jr-&A zc?C8c#$sJzp6AI%L4U|6c`@&F_(M@7mx~O?*tzW>gCip>o;{nT%jU_*Upj80DDNVu z9S(dxKS%d9@=yQx9bSC>Etbxn&7b`Kr@7+h)o5m#baJZ6Fj-i-4u*h3fQreaO`*uh`*!Yd#stU@=-iK)*tcztJuaskM^<M$&sf_hgsTl)My z8V{V}pa0?8Jo(~lES@owKmPKkx#sqDn2Jei%7Gf0t}@aFhGmkBrG(%!s<&lfO*&Eu zRs@+juYpQmF~=I4IeNHBa(72IMN`H0dftGCU?_kSC&^Trk>MzSJQvH(sk5138D~Tl zEq>EYyt<0araK%ia+!(Y(lO7s#|8u3fP)<7a)>I16h(AQcqd`F!yzr2#-lB?bafE) z2UxmzE`_BbnMAU$@E6{EJ|CUU-TcpgKFnj!zCvAD1z&vNKCZiSy(pbbJx?u$sTpW- zP3WsyDorAiAZ(sR>&=pjgG{JZ&#t7ttcus#_Oo;6K~`S22w|cb5{~DfmbwTAe7F^d zgzb@$D3-kc`1n~N>wZha$UzUy=7i5Wcb!a}*=EKCsy zhz`W~;dg$?qffm+fzQXM@BJXRe&||Mk3&55R5V3ZWeLD=*9dx2mw%@c8q)Vym9x?e zcX(7v>Wi6HTaBaX0Q(LdrMG)PgbNj2&!Gw`TH^$Qeq2rmnx>1gjm&;KR9ofKvh!-} zjy5v6HLA~j_U);h%j>Po$8w_2JX;XwTRoIdRz(3z0Rgt^%mHh#FjNcEabD-KE19J@ zP+jDKlf&s0aHFX2D1uUuFr3IxgoB4qkk&Nnt1Fp4yABlb2JZ%_WuQY(X#C>gr})87 zeJVG3jhb;a5U-0WbBU1VZ+LSmp{|J7mbdA;i8jk0kNfGZbVt8wU=64E{WTDM_E{fCrWH>apdDk+Wna zU_=NdiV2GO4!n)DLWG8Rvh_5eFr&VX(#j$X<6YUWEqk9`s*~rQ+|1X%^HVze`nd1L z8~N;)?xm==` z2+ef$`V}(Cl{U7wb1Rr(cp$p5(_{>?0 zV)nb)|H`>>^qxW}Q2zq_=57 zS7#sH0|R)RE~Zbb#UGMU&$l{&Jkk#Bwb5so&uuztlde(Q7hP&2oRsLd#2 z3Yz4;iGeuLfe3-30G>gw2vMX)(j+4Zz$Q55GrLV@feT6-N~tZcVEgGqvIl#hcYx}K z%3L|2L=9fATLwLJ!yq0{oblL-9OBtWk%5v456dJo0?(NfPo4>uFJ~6Z1Na=D$I7o_ zIB^Oz!eB;VE38iQ?v zRL`pv@aJDbkm<3)6(OcI)DYV1qphQdmZlDBXH+BCVKe(O1kp8fIK=zL6DfI5MFH{H zhInOio08F>o*2v%^0V^;7Atq&oEP}#2 z=&lapkp%5WyJRy*VPzqPZsPK}WU!<6R3C|vB+A*W@iD;?WfU*ri)=Hcn7xH_<617q-ts{>hyR{`{IQQ}L{D3}Z3A6m5&Pfl=Ur)>XS#6<-yiQq> z%i#qG#Z^7GqN`LfF$~LAsmD4;j}7UhMo)L2v`PE{zbtpNuAL*lY+#>*DN<<_zI9*kkmP1QpBsP3JIbrQP$U5nupj6g;^7}Y z&fa6km|0iH$3A)ovlcf<2#!?anVO~`i~oC@``P=_LCPvisF+!Se$d1M5xxi%dx=F8 z^fvWTHNR3ksd&uEFAtQ3`_&Z{xLX~xcXiX#)h`rZ1uZL{oK9P?P}2=`U2<#y0k&>r z2ezCe-IcM!9!jPU%cCEjtNfMS8!Nd&Yy~j$7t}_z^DV{#R+%V&xAo6O3d*#MoDR`} zq66A_mml`x)|5PjxpX=u01z$lcQ!*Tq>tffL|Psmr%NWPu#^iJg1f*_c=_qAJpSUV zC<@$h<277y)8!&KXK4D=1vQ9tRBSNLYn!$*+&#klHFIPSm^c6hRJQ|fflq`fIvTq~ zGhA^nNwag}P)U&5x=KQ7fbO0?T3b2DUYd@!vFVr3(be0>WlI-x_lG|~u*gR=8UvK6npR8u$k0sQ{PhkFzj=%+ z@3@@mxs?*uiQ^L;GSOrcFn2a}GtxbRucQJ+m6j6u&6Zbk`(0Gkml5&?Xz%amRMTl$ zN2Dt1*f3J~&!9^Cb9BPYKnOAxU|MpqltGVNp*YHz<@ZX)BEEANz?i(4VZK@K?BR7@ zu6nK%z;00(riNi@Ir~m3&J>W~K@{POpATSf;&A#hLPx=h=L2>;o|Ifcal8{CSZDGL zM*kq>K(#w7=glmnz+xDgR z-t-3Zm(5|x)eBHuisWpfP$KAtK#?C`fseKm?esMFQ8}+t9-9nrTtU=BMOwMEwv>`k z0VlgVXl?BzKAgZ^;KfXvj5B-w8Zb=BsX%dT(LV zR2h5NH|?`B&$StOcKSH3s%BGR<;c-fw;vP9Wh04BItN%)6x3J{U+TOU&jJL}ZrrI{ z>;^-C9T%kzx8M5?yqtnVQNjtVCDkKM-YFj()*>I`gb9~z=HvPxrXi<&p9@s$D!b-{26jMO~P#Endh_Xw~%qlFm z!l8r5S=F(e%Gnj@`edRYTTn1q?5C`}1n*Hd-F*Xew)Zk)!8G~ZjB3f-f9#pNrPVXD z;ps#g!_db-$mT={H8~Tcxd*cC^H(r4xO%vlFb1i<9(UFF*Cr&yrmBh%Q@)skN) z(+Me_Wht{ss-sv+TaaM;Uu{MO%<~3pH|ew!XEawdY#4^*qv^CxP(3#=Sfc|3Zl?$Z zDkxHri$>zMh}U_NPEi!uAH~rF&Aj~DHe!i5*RQ{vH5*o7Dl+moB|$AzO}~%kgRT7J zKYmH;v353o?iLnZGaqEHwuwVkz}7qyU@H!Vvbs{y_de0mMDwxJvK!mZk0&y@K#25J zX?ZbTr<;L+VLIA-unb`gj`f@hHefTozg)0&L(fXhRnP4DvLCW0&==1Db3P_mX3m^{ z>MMT;p84%fdP%+_U#d98R63R?)ldv4O79HR!Kw(#c@4!CEZu=~NXb!7H7t{8G)gL- z0_B}#j^OsVB(!uWsw@N;92^ELBv?3~oa(g2Yp?8(iKWW2Qf~UddMfM65lw5$nu0uV zDS}B`>)ZeG6JCE~2di&b$>keY;VEzn7dvOK#j+?U4N^b7hLLEL1N)B(UjSv&(Z7V# zF29@7%3=|gjKpKKxA%xVw3=C08Pi*KD2kk;C=z;#Omo^uXZ6G?@?{6bIU@)%7UWym z&dZP@&g^?zrX6Bu?URpwS@|{q<{+!&(jivlWOO}EA{B!i7gz;!7oKQ6h5BcAp`BM? z8>9_4o&lHY%;`-=hGXJg70z+2(gGPe@OmgIDiqDLXd*#ZPoK;ct0?ESMby#S&F0s4 zFcgilaL!Ctty>Nb2`N!fm}=(ndKl@B^4W*T-YP7By6qym_^KS3Yp%f5--Xe(Hsl?=^F;(VeuV(n%6pG!IzanhW>P zVoJ_GL8XYKaT?d4shcUllMUFzLlFVDqA2HHKoelQC@C$H95|Lp(AM59Ij`z;aGp#m z#(Um6$i73z2>Ao7U$c^$>6Pd*V?5>dY6+;Vq{-tyex4sZ@)!*@HQe{v+o@Sljh5E3 zR_`svR{0e5P>(8$=_`s#INjMz>!}XP>dVlzNqx^}G}9CcibI401vqffKRCq5aD>9D zLQt?%CJT98!!$;RQv}$cVCa_Uj2q+%#T^o@jrGc4xUc6}+o{)*I>E~Ii{)o(SV9ZUTaz#`L9y@``bCk}si+K%L>L?#p+Qk3H#RUB zZB(U_DKtX^iI9Y7bVkq27)NBX^KohIs4iJW42qneAy|;nH?q&L@6PbXb|qVGXF3#1 z$-zpq#;*$o#eShYT=2|A26j(g1TiF%N~U9^%|ymRKDmvG3s=;KckqHtccqBy9twB+ z@yC)xgJiLxXoTMGL0RN3MH!59@J=SG%SBaH86lsS-l0L7+dAkz-9u@0v0aouTeGK* z1SE||PO|&J5eY>vUAmCD3ua)L!r5ginGk4vUXJZ;l-cl;t*w0KLw9l0Jy*+~EiIm1 znn@VhoYC&K3t4yzebm)fqi$0 zC-oFldLk+nm8Fyh3IXWuAEc$R15D0%>~qAmVj%?_6AtY^K~qaR0iT~0D;H8!78Wh* zsb!)aMtmgB&wu(fue`aPWpn4sU`jz{fiV4)oZl;F0HOe+<#A+~qqMA;fXC12_D;IH z`lWp~@lc5E72Q4;!C*l8=p)e>LxaNtWO*y>P&^(_$qa#tDw|Uz6bG3&OQlojmOch5 z_5g@eyqTDl?HJQ<9=BU-tXO|GvmXcSBhRqM#IGr4pJM|*XCK=Fkkl`p9Kp=K6Hg)+ z2hN^qJRLucpqzJ)4G@H+8{dG#UeawF2o1EmyO(4vjlbB-S;EAYKEk4;vY7hvN_I6K zmA1<9!>70|u@1%K0Q7TAf)zxFU?|D{gD2=88D@5UJqs7j#_4sDjK-!CrscD`oi1K^ z`Ar^s>SYT3LGJy~EzDUyOGa8JWW@=l$Mm6P0exXbA;p10PWE@we6o!>3ulOzoOpAo zfJo-MgF&AFJC;r`Ff@#5>#2c)ID= z^2}DgK6kFYTN9o>aoT6)W0>V%DgX+ao+6eS$%-dHbkN+UcOZc?^`l*A7YNwFgqz_G z)pF>7GuwpM>GmFm2BQQ@edp+-q;)1MV&=|jz_-)GNIb^=gU1=@9-?AKIhv+`buM8z zB6feey`96yPD=lF>B2cQ%&J8+wu~t=!6ng!$L|#i=)(^^PV?z@K78xjv*B0b@;OK( zQWN*t6~>%nTGrd(l3sR2NeKs!p5*xPX0A%Fkw|8eC0_w(AQ-^ybTE=M86JruQJPB~ z1*h!ZibSGlXn{f%3XiWPR|th__3)jBEleFE0Vp;#Bi#nX7i# z0~?dsiXuc6mqR?T$VF+X^ys7)0&LA7G7^*YMu6@2;&96O(gsVWQ{u6eOkm6M3Dx1q z59*B2IAf)5hSs6IcnTEzw6hPlAtThb$kbJn*UI);V3em%JQX3KN3gPU;P4WNIB*~I z&|_YZ;+gE_-R0E+6tuhXL;*iptf#M6Xok!6xdKAnGEkJW%(D$ss;D#PH&7EUrERd6 z&h9>r9X!RVt5yg=rHyhnri{uiL2GM=6y^MGFEbiyWXWGVk(gTTEsvKI`%m)tldq6S zrMUKr)vR2*7y}d2m;}|;0u}Ylg>Y=x=m~l#E-u1@i_WfIdV2>csVWjc=2L+cFKS7h zLgv5SsuL{aXO~Xv_*G?8B!P5F6C#Xm>G&M7?gxiMs=#t?I-Le9E9*?c&oi|YIo~3m zWnTegzcTr#q7!@NjMtsqMolZ9>=FnzOv50W93pL`a&p9-xVnmP?fEQQH7|yso~~ZHc(Vq zD1eyIgrzsyJ61FDY*)Y~B$J>cz`)=z9jCicr5Zi)w3n@C=Ef<&wn!vX0%Qec)EkkE zr=+l^BkfBGVmalD1zT2T28j-{R zhMCGud&bk_!#4!Lg$l(Nu*vY_X!jE6a63F?vqUP%i4&)Z495jX=jgE{951X2bIGd3 z6#7DFroo{TCuu#=CV8@gaxVL}WuT<9rx(r8sj4ccsH6z-2_{p+6hzIX@o*DQKld7G zP3QUzm$Uq`g&4YyrCVnc&Pyd)8C#qbE`DKI0by@Ic51b@v`g_zb*Pi2wo0BY(_tW~ zftJ=l84bme%_9*hMj}EM!B9ZhX_jfp@`7Y4l?iOQxVU7&qX66Xyg4h-W)Acz+4RS4 zt?``5v|krF>|h`x3do>}W>nI#a!;O_DydY0NW33CdqrF>aDIZF1sHK`JO9PAvJEJq z&1KO51MQZfBmiG#0ZrrN$yWM%hA62ju*v*q*}IiI+wW%SW%FtHNhLk8A)1@p*}vx~ zi`Ohfbx0NWtc4Pzkr-0N#iF#NkYLb{HFac<#9N!1!RxQ@WdGq~OslNmn(Hp7pd=(T z%(F4sL9L8k;35-6E(jF|2#12CA{y---K0_)s(W(uQU!1*wy3?P>2fFvph!3_Kj&B~ zZUZ~$MA?*fLF#a$|h6Mw(LwGL$54%HCu3b4jSb4 z>x`x}NSiB~9<&Q*;|MAq!=DTgXe?q-yPzKXs?F#{_=dd%kGk>1a}}wY+fLJRvIF6F zJI;w(Dxft?lj#c^ShaK!9@Rz9@E|*P9$=(zNW9M3ff~vEV)3K^1Pg-UfXEFCyJp^&D2L{qR(PfP8rJ`M?AK8++cB#;=(YBY03CyWGE`d z#5|FZjUJF??LzwmbOE*ofQ)4VBSTT5sU!eik6YILs19j$iIQp}mCCx&+o`y4I^1Xa z>ZT1OGXr#4b6wM%pa3@BhnhC1m2royVV0|1mSaPRm2(iqj+`(VN%WJ{qB$=RUc7xi zyf0Og&@U*k0lv;RhTWLEgMzSrbR6uChXoQ{>AHY$v;fCo|FC>!0KeBGeQ(ZG%cGH) zRK+t*$SE=~V%eVq>-^=mxxtrz0Oah=njpW{H3nlnq>M!NbCCSH-H+1sAcxZzG!zHe zv=OCXQ(>PAUz^L}o}J}`$DL9V}iHA_~^$5c(M zcOucG$YHm1uyFZZgo}cBaWORXHphR+W~pK2#RQ4~+m<~tOsViHmH=DA?V-UDl3E%C zg`h7WGvo>ix&S*a9zGi{+NrvvD5s!ent+u-?L2Sfk<8Qcd*%$YCMvdBIlC3nD4A4(!9dDATEII!@zm#ZcD(UdEYj7#ui!oVKPeYNwZpS2T^Y zS0%PgQ?_eX&8%RQ|sr=eSpLj|~#Rf^z_~m2r*B zE_`6$oDk6YE9d$4eJNm}5=jmaOAkTLi7tOMOz}w%@g%?n55*tcjD96OLtzSz1_+Jh z%(J`O*th#A>4Yu?AuQ|Mymnf{9dNVm`b$~9aGr#VJN6!A$D8|wayZ#aE?6j-qH*St z{2T^{A|zr-0o8kL4wow5>+ao$IN939%-R|*xnv2RfDb*bpOcez$VJRydu)5I8mC(& zToS_X@k%b=)!i>GHivo^!V3Un#}#c|`2GQ@3%Fe_ic7Hjjzj# zL=9ZR9s}UL^{E?xAxQ5Jt+C`IWNLDEeYEi=GJoUb(d2Z z3ehpxC+Z=?-6Oc%?z2d%EKwlw`uza(4i1O_qbve^PxEX`*j595!|d68So*&U=gnf? zg6WtT=NL-l*VI)V4pA-jx;)}p`v)Z_KGQ#DVy|f0D{57TDu6yCo+!n!f#?Vl891F3 z7KWuVEU6vDPqb#!iCTTAPr`mt)wQf59{%g#fG*}3~L!bH1v1xaK? zaWd*G@;c#AK+f;#8KA#!aCBMxdl`pITdKXKlS4;O;8Y#VnbjZ?M3RdtXPGCA{bSDb z3MSwHZ@`1c?UHKyz~B&xcoJ{Oll!eKknkR){vrKW6r4_n94JDJ5k-|qJb{H2C@BgT zAfzJGA|6f3u52sI?@V}BPPe2aHozvu)6V2*=M1+mWT>4_G*Q@speT&Q`x#000Tz&1 z3iVI}Llg%886OYp?RdE4*Hf}gzWwwy) z+&PmxlF13r2(|Ns+jb>k!cbd|Y#!SNuyhc5hg&7T&fVq;^aXLf`YBEi0K99!2KY9+ zv~sGB1_?LI;yRt@xq3xe`=g}^mxfq(#ii7gmJ>@Qd2P!snvb5w z<8)(9=&*&&hTH3=wz`6V*N3p=jyDq>iQ|+D5ATIZ|2Ppza_B%KLnBdHqcd|(J&MXX zMLCun*gG|{b_S52h0o(fsSx3!-6BA#Ao)83y+g9Z za1_{uMFmno)zdn|0}>rR}{NH(Q*gIXMp&M%fjj=%CYdFVWH6*`~dY zHlJkI)+5YbQjg1}qNUGqb*gU3*x}L@^I5fg5zSp4>^*vnom&qub5VnM1Vhtvdp8U5 z`ifhnuBM8TaG1g92q%s<(bqLVLuozliQ2Ft6>)|JA{;-_EEk{pno26FN+rTL=kAs@ zMqM+rFr78C?g zRYiXHLjxnkv_xjBOFXt4r_&?(ziCrHGeDe;4L0^hKFQ+zd#@+vi)T7*CTxXSC}(OA z{UaR=Cwj8}PLMF%n+#I$Mm?pc?%9O#u6t~NBHkJ(qI5@yqP|>!>K*FmmCf7fYVDVB z?H%-Dg_B&>P|7t|Uq)#-OyBSjFK>Q}&XyiwH%&mP5N~3sCiT;+s4g#)3(xUWEi|2I zLp7zs&bw*07(DcJ^>M1T4UfxBeQlKp2A(Uh1=DBZ@X@cV`>UO!z*Ys+abe_H6SmMy z!xWK8Q6N0;n7pICUb1-Jsc`ufD_ON} zT`exvN!QRI2lgBz6-&zV-|hTbaR^XoZ|#zWfI**++S*DyK9Ai04r_Aq`nixHJHcHp z90=$n6qkN9`HE*Mx@JlaZJ36D*YEQop}3-uh^6T18$>e=Nx#a=O7I7LU_m01qHkaj z!_>0^+wE}UcDYf>sLb1;wteKIc;=BT&Jk+oc#hm8hb&Xh=Vjm;@z#+p`l79}RWqA; z6dz%VPLxn^{B0uGdj@QPYI^&_gtz#tK#UyL-rdF4*Y^<_h>4eYCzecV)Xk{oigha~ z@cC&U=;f7{-(sL^2)DcJ5{Wt|PMV%i)keio69hhu6X4 z74w)@RVB|qc>Dy7$6IjNth9H_1XNl|r>A>>v~Ca#`6((1*;8@vbgiK^29_C12}La|=12}bG!nr=df1ee6^Tx~shbQB#ANW+{Xzwr3{id*$te%dpHybb#)WW^^-^ zm5}1VNtoj95-N_aVN?3sw!YcBUpxc&EW5OOx%E^Rp(nfqW+sU_$A4(BINEfI*I(Jg z!b@k`!g=RrA~rB*=`60g@-p@uKFZ0CcAk6sbyi-!1YePlR5nEldq6?1p7WN?X32uN z>}fnfTTeIJw(e)u70b`GWz2UG*lzKhz?;QNj6JRnuZ4=u#d+xX@5t&BE;0cBjU3n;^ z!E_(@2STI_yekjiz$93K)90}HV_@)wXHj7YwVHs4k+i^UJ|tqU&zCi zS6<58=?$`*`{g&bb9nCwQMgp>QPG@JW5tz)T)ui4RYk=jmiX4Ty>z$so*`QBE}D2t zB9Rza;BmR6MP!}ZSW)Kkc}2jPs3ekzkj2Dfd0jpn4%aBCCuV}l?a4F2$P;4c z?`fZ14v%HiY*ic#$NK3UK50*Kjcao$s01kJE2Z=;lb@dkm=eG~#kb803QvY9+Ur&Q zHq*wg$YNr0=-6?#zP1lV^uf=)+$cpbU1Q<0*{r^FsT9FZwYT%)GjEa@j^m!7h$bFD zg;nd9F>lUHX`>x)ZsF+u6A1rYQQy6#e5!(On8Z^lke;@TErMdb!$@2~Fd%HOoP#GK zR|JYg3n_b5P@Yw_mh}CH;}HS6I7P=Sko>x!FeLeUDyh-kJwROa&Y`rZNUHma15zU? z0d`U!FR)*nezW0g2JOO#c%Dp4oe%?C{vE|5jJwb6u9inTfiGg@~PH*kTYZg;} z!iW0QCP+^iVBZ7ANAWZj5qi{P`g_P>9ev$A^ZaXcoa&R-*SQW~C`g`1VR-@9UALB5 z_4P!P37&fKRSxeFuPvU4+~5?=t!eY?S-*A#A+KNLk~Y7xgXmyP$Q19E*H$br4M`8| z`*??@jA3i#shX>j3LvKQIPB;-V{`(qs;w z>Z)==;edGg;lUW)-94i7m1znt)r;5dM|k2B=F4(S?3u|+P{|8s;Hv{RsR4q4z?qtW z%259>J^ce17(&^ttS-kN^vQGjyN6|Ks%{z?08??`ar*Fhd?>cI(fiq?7^rD!+6Rx* zA8*SUc{*`ZNLfn|-=kk=bCh>zPJr|6wO^sX@r#6iGGO&C(@59atRh?dy>p<4C!T(V zbyqEC#*%8Oa$D!J%U*%BrcqK^#C6wR&MR+h=fvq&o_^^yZn$YZYi_tiz-i?UEhV>| zyJ!Y$E?vgnqsKXUx{WQHcQXHy**IKIG+pMPFZ5ynDX>W00SiqxgrOx^Joi6x0kotQ zR&IidU`R1#(Lb2-l@n8nbPS|$DYMp^20%g3Pp}{$kwJg=5dA~LSRfbLs>)KlJ`Z}z zkezP5!-JTZ85cBJ0p#-Hb_uYBAqdJ@4_oACflWrE?A(+}?d5%=i3Q>u`Xg<03?4%_ z(^)Iw00k7M3n_Z00^>Bm6!h3|6`S=X%zHUR@lL1e%K8J;O`UD~_VD~uZ=l5tobGe) z9g6~r3ahVJ&gGXbLzeBexADYdFEi9Lg2$65|=6fI=#n7J8>;DV!Hm^48acNMcOl%Bp~b?WuT@zTQFE`l2X`@W2D1 zfXs4tb@a-NJ3z?qm&J-smy={HMQ2A3!^x4H>^rwRfXnGY3TJ0Wil0JegF2Cl(%g5D z!DKhN%!P*{%9}%kpZp0=4Nh5L18m^CM;>hD$epbVojd2b zrpV`&!?G6vn9w5b>ec7@_!sKdd@8IbtUS+UrSSFA1nWpKKlozq#%2ia9 zme3y==J}Vl(APE~ZKn$tiraguT^<*OB_h=f&OF~bgDynet3yTZnIlY|&G@WjjrH$G3vpx7c z1vs2e$?>t232B|=r*&om**p;dZ!bl>@6VJWO)>rqVm@4YI&O=+4v{_23in;59 zH&9zvL0n7n;+D7A`toj^ID~YP%ZxMp^Gh#Z%7WQ5P=K9>jhWbb7>Knk6{Mt!PT_wT7AbQ%Q^;8!f1Kl|m&nb9ZJ_6n#sv^)~e z%>*L+6UiHq53LhAZ`(s>ecTc~^t6_wx%VL5vF0)FJ-*Trq;Moa_~9VR4k4txAAtP> znv+#nk5^K+*@G`h4m)z{1V8!lQ=B~1CdC$ub5mF`bY1eqb=O|P4I3`U=k{=_y^T$q zp62xNHu1bUJdt>aw5C%(vxXb4UQ1yxNM~O!Pd>ew-u8a^qrG5WTLGcCx}35P-0$@e zOD1S*>ym*9Y1zG#CI2V_ZcwthASqI%bR81}&`yWF-9xd)GpH0PME3L!Fp`QRzzzo~ zsVJ66qpQ7_o}K{!+-{f5l>38TVj~F}kDq3EBu9RYO2AWy*HeIEi8stmBC*aBiNwk{ za)#UDne8LzTPp~wk)X^;Gd+<|2)y_(af3af2;|b2AIk~{rKwBijoF5O; zyS#_gLK7on)>kt%p5L;?TKtPS%4ciu##`hP?|arjvV>+T!yOy%xskW_9AN+P<7|Fw zC(r%*H9k4x9$}Q}nvq?4Nz(`h{9JMMrEL23vuxXckhk{iXXlo^+<4Ch0hn%>oNsu` z2&8G@a;lV9mIyU-aA=tJ&Td8qM+lYrd51kOc@MHz#Rb^1XWOy_)Gp`fGCV8mrcxtq z(Az&qQquqk7X*b_rl)n<+q&o*7#N+wrnl>Ep~aKsLc#%f&+C5DtuPdvk$FYm))IVJQxYg44JEtT5kYZh?hHR~ww z2k0IdT$DRoySsVz`7QKy4vJu+av?`I6-7ev zva%v9psTx=-kyHRRWFR$^$8K~$CCWoIl6>F+SHDnC0g4I>C@Kz-j3jAm z?#8Y@}Hac5^Ar<=M2^cPIl z#Zu5TU4ShcK2?WQ=M@Klm9>W*8IID?)q`&7(#YfN$MXkBoH=3dG!q(S~hyM3TdRm5Lf0g1m`>+lO&ZR;1| z1B(k|rfCX$YsU0iQ4Q_x?WgfX3#oWo(tvkLSyl3kz$TKTz*a?tMndt7KC>vNG1xmy z_dq`uh#*8&Wx1%ncDMJ^*3pdx5^+sys20FS24b8%-pX(^nrnRnAx{wjZwN)nTy2>@ z^nPcRZ8CD_tmoTaI%{6-K(SOh`cKi=yNkFsOg<~3ELBSN(-kDQ*_`nAA+P~@*=)^Y z&W4fQV~7B)X@Y~QBT0djkj;$vgM0~EO6}fX@2^% zr|4|#mW>p-W}VY%gFx6X%(WRcwP=>XmbZ5E=H}g~ID`*<{!J8Ac$P zp{lC%sJpuR0HknPUtc9^u-$F_G@ouGX(q^GvVbq-Ef$WrX#`rHwj)}zwt?YF& z>=W!(Rp!gs=h#P8pmMI0C#h1+E1J|s>=k|zb;^R-eOy{XCd_J#uxAUIsnx^#f zXU?vtw73X>=2LBSH227b$$BUKG!+3c<6AElGFKgsCjn_u3H>u0+fG$wnI7$@yF}{@ zps+ARRc*O2(N3N`%|LV*fU?3OrcJMrZJei$w9(erjfqJ%bE|#|{AFhtXXYffZ=5}O zgd7DiD{t2j73%7%nKNseguYF! z9h^MTig*|GTxZ}^DZqh(WtVwJtMRh2L)1hO3E|9W#`gNM=d7i~H}Ul_m7| z4oTHJZRm1tT}>rbbrrJl}d?9eKT6wX>Zx&*)uR4PhJ2p1L5+14X{ zcT@6ohlJrJrD6Izhd6PvnRqH!n@g22+~+O8LO_-i&`h4znHQTAgE?Q`KC0~MlAqbh z?me^)9YQygd8xmCLV6h$PnA;e^N;d)9N;$$*s^3GK9hn+ODO*zFa8ACO=yWvzWS2> z_5b=AE&Dt1dA-6pKHGv+GMQxYCG)xO-diaN7fML}^eeCP*w3Fwi>B~+gcOrG>zzmw zEDCb#?N_mE?i>K#*u95e|NMDU!wC`EKi@mGHA5#546tDSOv0g{eAc7KPtn&kBtSpg z6hJ`j5Uza2Rt-f}r4}(bG$QW<;PZMU)j%CfFjpL)06m@kboKROVo5u&uC7W#_okC= zobKudAmH&aV@92PA5F*FY3VqPX60yesa^_w6?j};uyTnC=EZt;FrRa%JuxFq6=qqA zQ{8(xHL#bo8Mo8Q96Pwmsoqva*}uKOlP82>_L~T7fG618w}?64E1}{SF2zGGi|GmH zBhNl%{MWz#gy#L7xE!vtk}j6MYg$8fIk@f4tNGwf*NFG;9vbAkKYEl`p52OSshNWw zlJgou+*q=59veS+17$@;3?`!d{IO?w-EGv1y{JDxwM^Z=_)X;1Bxbzy<|ElT=jk?yXH73`9l%2#15roK=UO z(l~zX6vNWLhRV_sX3n05mNMA8<1n3ry?NSP1r!D2gO$9scP!~c|PpHw%<%(3%Gfrsfvp47g?nT@`W*R zJ?{AF6TjBK^Yuq*JJN$pf}IVxWo>JM(&{2U`mx)%bn!wI;9%1!zWe=0Iewr?yc7b2 zaTkS@Cd=WkziB;}uUsbJI@a0BBR_wNu2bFOtQ2qZ)={Y%j6kAVL<_P)2c*pq4DHt zbX}LvTUT32O?^3?&He1!dx)5pI}9)Ilo0Y2jjDi7h&3i3YTJN5%OkBHPrv40cG>ocqa9K0@wg+DB4;{)jt$c+6bT>M>!_(H6JduJ-`K`;kH3zQG-N94d{xOUg#AWwX;{3r&*LSMPO$&raS7u!% z9rMh(+-VKf0{ZRj zQ%AM_2?yN^DN9vQ{i7KK|L4a%GXn5`2H0<3#nv(Xi3;leF+@?jg#tNNr}oDN-QRoU z5$#+5y@}?7ol@0S9qL)F;dOdl-2B07_|P36AmsClM%vFFdxl>;^fX3N7Y~dzE+p3t zEC*bB(^_u1ZUbJIo36nDe*E(%*uCWdZbx>)3+Dzw@qBK-hsBF#Q&n0j7o|N1j&R~| z6Ha?>{p>exj^`^Ys%-rj7#f!Ma5z*#;h;nz0!G1!cy>9rtG!P~VGYy3?{>?I-$1}m z2eA9$wKdxBfq1aoOibJ-*0>8 z424$#bt;wMWanOv_iQDS?wyE5PJlwalG>lvQS&dGc)APV{}iwRzRsrTJnDa5NzH%w z2n^&Bdjl9q47q;ri(ly9_^+SSw6_zt!!_w@xdJ2;DGJL9`PirKWZfmpC3o!T>*KpW z_ywDvep7OIr`tJx)K0XvORGw_@4j1DIe#7|7CR0d;(x#QDAL=ON;2ogL=gs?0t>_br#$|P`1)@AtSP+s3L_%=|kiiepSf}Y! z8~ww>00e^pX3d!8O*P-?{a!(aVUPbD?y1E0T}d6zYycog)sF%hyQ zcT6M_%v&;p&wS=?TG~1|($vJErc?aSH-AEPZ8=NVE+QH=?S%n~tc4|&PO)^&0`B|p ztu(f`)7{_ClP|r-!iBSV;P*d@+v_EjNS=rBPMI_l;>Y}XGkJOI4p9T$wfhh~?Y&gb zsggmAvluQBuZu9puqB5<^6TMv3_$jc7luQU$0v3B^_@Vo@Cm z13iPBIN3x(OUrB3S65L{RVrNerVh!kWsZFQycrDlMcKAQGv1E zX=U7$b+ikY&SAlvnF6M+!6A0;I7oarA)}RNlea6X15oY!+968-=^q%94JrVEpkK%) z(iXE9_MxKE*3>~qZyx|&_uBxTC7SSu4xeBo8Uqj+q=xDm4(&U|@zztA7&*d37sdV> zLV@Dkg|7TO?fiu>vgXa1Wb#Z34h~5>EP4mqInwqfO@n(#nj;e?>?%&eX2~1XRDWeP zJ==a~Yz6!xumNVEo}9z%e<-Eu88==%AGnixl2>-W$-n>C5BSypzRX~21fSO0Cbgk=sN# z(X>pq?A*h5zWy`1ntJegeKv;9d1JlMTB&WQV#(q;LQaV%l5F3;pVQ4B6B&+j_;917D2_9|caQ34 z=iY-1CWpvnURQ|HU@cCUaKp2_5ek&Npa_92q5_ z07_oEXdxv#tEv3!b@aaVH5#*l`XaDd`K0!VjlXv|D+|iB$hirFLRW*NqJCS6@{Ca--OCM#!<(EpIy1lQLuYd2SJo>|D zFq0+%Zkzcp^WLa%%LlLGqaPA)PduIC7tg%J_rLiIM*1SsIzu^ka4U+`4Df~gTzcse zYRbxFob*uRNp@{JAO$wZq{4p5uLXE0${5lAL@XuC^E5+LZ3u<@6chyIH5n})IaG!R zA{;t;l96~!?yIgSm0Y^9v4!KOT1DYC;Po}!JW-T>; zaRtM#<_7geV2e1zJ)1E82J6rq8ve9~hQ~bkW7+hjM$N$BR9}noji3G4{JXDwhgTli zNvuC98y+2bLUYTKVj3$iU(6r=(Whm^(^3#!@_+m9AMn^i&w@gns-zU(cX$j@usj7-;8LYT;A(t#)BmnIh9N@+0x6s|%gG*N5 zDiasaR0Y*xpNoRz=_7*?I(zzrR;sFs40MFT0pY7lQCwBT(>FJ^)7bJhfDK{WEn7HW z+G0C*9vT%-V0v9GHN}A@S*Ab*VGy~y=TUP!Mdt%~<9D>%|EHoR$8Gctl zEd|y7;Z6>>Z{tYM8w{q}FwiHJQx{SGd>u9a?@C5qu|a(i*b^=uc!`$|*HQl`)zto{ z2mkQ6vBM1SJ(wC&AKUUg|L~9B;b&iap048qIGrv5cvcW7h{$~^m1OPJEBW&8ew_KU zregue+gtgMul<1M9(e_aq2TelZ4Cg+Bpy#tJEMv(eBnM;EnO&A{Qcdip}rCN zhK4Y)a5`i;y+kL5X$lfV<1KA)$6H*CZmC`L^0j!esA} zqoQG#-4w#!N=gc9g>P+`xpmC*6GtWnGd7^JnpC}oNtUFo_c({zUZ;6@4{?29Qv839 z!$+Z6_FO&F|Kbvix89Zj1Got6v#`xP+dQA@uT@j`&jCVR;<-_Bdheu>V0+UZzV?HM z_}bt7lmT5cR#`WnQ}qkXiE!U{n|sk_}CU)iVKg|^Nx0tsZNFUSFB{| z{5b%$_4e}2ldm$^GmO{k$xkDdDla%y+g~{x!o;JeyHCPv0H4Q0RYe(Y�z1!k-`P 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 + + + + + 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()