diff --git a/LICENSES b/LICENSES index 7957c93e8..dc86c91c7 100644 --- a/LICENSES +++ b/LICENSES @@ -58,6 +58,7 @@ otherwise. - static/themes/default/icons/repro.png :: [[https://www.resiprocate.org/Main_Page][BSD-3-clause]] - static/themes/default/icons/roundcube.png :: [[https://roundcube.net/][GPL-3+]] - static/themes/default/icons/shaarli.png :: [[https://github.com/shaarli/Shaarli][zlib/libpng]] +- static/themes/default/icons/shadowsocks.png :: [[https://commons.wikimedia.org/wiki/File:Shadowsocks_logo.png][Apache 2.0]] - static/themes/default/icons/syncthing.png :: [[https://github.com/syncthing/syncthing/][Mozilla Public License Version 2.0]] - static/themes/default/icons/tahoe.png :: [[https://github.com/thekishanraval/Logos][GPLv3+]] - static/themes/default/icons/transmission.png :: [[https://transmissionbt.com/][GPL]] diff --git a/actions/shadowsocks b/actions/shadowsocks new file mode 100755 index 000000000..bfbb8e67a --- /dev/null +++ b/actions/shadowsocks @@ -0,0 +1,97 @@ +#!/usr/bin/python3 +# -*- mode: python -*- +# +# This file is part of Plinth. +# +# 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 . +# +""" +Helper script for configuring Shadowsocks. +""" + +import argparse +import json +import sys + +from plinth import action_utils +from plinth.modules import shadowsocks +from plinth.modules.shadowsocks.views import SHADOWSOCKS_CONFIG + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparsers.add_parser('setup', + help='Perform initial setup steps') + subparsers.add_parser('enable', + help='Enable Shadowsocks client socks5 proxy') + subparsers.add_parser('disable', + help='Disable Shadowsocks client socks5 proxy') + subparsers.add_parser( + 'merge-config', help='Merge JSON config from stdin with existing') + + subparsers.required = True + return parser.parse_args() + + +def subcommand_setup(_): + """Perform initial setup steps.""" + # Only client socks5 proxy is supported for now. Disable the + # server component. + action_utils.service_disable('shadowsocks-libev') + + +def subcommand_enable(_): + """Enable Shadowsocks client socks5 proxy.""" + action_utils.service_enable(shadowsocks.managed_services[0]) + + +def subcommand_disable(_): + """Disable Shadowsocks client socks5 proxy.""" + action_utils.service_disable(shadowsocks.managed_services[0]) + + +def subcommand_merge_config(arguments): + """Configure Shadowsocks.""" + config = sys.stdin.read() + config = json.loads(config) + + try: + current_config = open(SHADOWSOCKS_CONFIG, 'r').read() + current_config = json.loads(current_config) + except (OSError, json.JSONDecodeError): + current_config = {} + + new_config = current_config + new_config.update(config) + new_config = json.dumps(new_config, indent=4, sort_keys=True) + + open(SHADOWSOCKS_CONFIG, 'w').write(new_config) + + action_utils.service_reload(shadowsocks.managed_services[0]) + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +if __name__ == '__main__': + main() diff --git a/data/etc/plinth/modules-enabled/shadowsocks b/data/etc/plinth/modules-enabled/shadowsocks new file mode 100644 index 000000000..99f3b05e1 --- /dev/null +++ b/data/etc/plinth/modules-enabled/shadowsocks @@ -0,0 +1 @@ +plinth.modules.shadowsocks diff --git a/data/usr/lib/firewalld/services/shadowsocks-local-plinth.xml b/data/usr/lib/firewalld/services/shadowsocks-local-plinth.xml new file mode 100644 index 000000000..93e10f2be --- /dev/null +++ b/data/usr/lib/firewalld/services/shadowsocks-local-plinth.xml @@ -0,0 +1,6 @@ + + + Shadowsocks client socks5 proxy + Shadowsocks is a lightweight and secure socks5 proxy, designed to protect your Internet traffic. Enable this service if you are running a Shadowsocks client, and want to provide socks5 proxy. + + diff --git a/plinth/modules/shadowsocks/__init__.py b/plinth/modules/shadowsocks/__init__.py new file mode 100644 index 000000000..5164d880e --- /dev/null +++ b/plinth/modules/shadowsocks/__init__.py @@ -0,0 +1,126 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +Plinth module to configure Shadowsocks. +""" + +from django.urls import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth import action_utils +from plinth import frontpage +from plinth import service as service_module +from plinth.menu import main_menu + + +version = 1 + +name = _('Shadowsocks') + +short_description = _('Socks5 Proxy') + +service = None + +managed_services = ['shadowsocks-libev-local@freedombox'] + +managed_packages = ['shadowsocks-libev'] + +description = [ + _('Shadowsocks is a lightweight and secure socks5 proxy, designed to ' + 'protect your Internet traffic. It can be used to bypass Internet ' + 'filtering and censorship.'), + _('Your FreedomBox can run a Shadowsocks client, that can connect ' + 'to a Shadowsocks server. The FreedomBox will also run a socks5 ' + 'server. Local devices can connect to the socks5 server, and ' + 'their data will be encrypted and proxied through the Shadowsocks ' + 'server.'), +] + + +def init(): + """Intialize the module.""" + menu = main_menu.get('apps') + menu.add_urlname(name, 'glyphicon-send', 'shadowsocks:index', + short_description) + + global service + setup_helper = globals()['setup_helper'] + if setup_helper.get_state() != 'needs-setup': + service = service_module.Service( + 'shadowsocks', name, + ports=['shadowsocks-local-plinth'], is_external=False, + is_enabled=is_enabled, is_running=is_running, + enable=enable, disable=disable) + + if service.is_enabled(): + add_shortcut() + + +def setup(helper, old_version=None): + """Install and configure the module.""" + helper.install(managed_packages) + helper.call('post', actions.superuser_run, 'shadowsocks', ['setup']) + global service + if service is None: + service = service_module.Service( + 'shadowsocks', name, + ports=['shadowsocks-local-plinth'], is_external=False, + is_enabled=is_enabled, is_running=is_running, + enable=enable, disable=disable) + + +def add_shortcut(): + """Helper method to add a shortcut to the frontpage.""" + frontpage.add_shortcut('shadowsocks', name, + short_description=short_description, + details=description, + configure_url=reverse_lazy('shadowsocks:index'), + login_required=False) + + +def is_enabled(): + """Return whether service is enabled.""" + return action_utils.service_is_enabled(managed_services[0]) + + +def is_running(): + """Return whether service is running.""" + return action_utils.service_is_running(managed_services[0]) + + +def enable(): + """Enable service.""" + actions.superuser_run('shadowsocks', ['enable']) + add_shortcut() + + +def disable(): + """Disable service.""" + actions.superuser_run('shadowsocks', ['disable']) + frontpage.remove_shortcut('shadowsocks') + + +def diagnose(): + """Run diagnostics and return the results.""" + results = [] + + results.append(action_utils.diagnose_port_listening(1080, 'tcp4')) + results.append(action_utils.diagnose_port_listening(1080, 'tcp6')) + + return results diff --git a/plinth/modules/shadowsocks/forms.py b/plinth/modules/shadowsocks/forms.py new file mode 100644 index 000000000..f6a2f1777 --- /dev/null +++ b/plinth/modules/shadowsocks/forms.py @@ -0,0 +1,61 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +Plinth module for configuring Shadowsocks. +""" + +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from plinth.forms import ServiceForm + +METHODS = ['chacha20-ietf-poly1305', 'aes-256-gcm'] + + +class TrimmedCharField(forms.CharField): + """Trim the contents of a CharField""" + + def clean(self, value): + """Clean and validate the field value""" + if value: + value = value.strip() + + return super(TrimmedCharField, self).clean(value) + + +class ShadowsocksForm(ServiceForm): + """Shadowsocks configuration form""" + server = TrimmedCharField( + label=_('Server'), + help_text=_('Server hostname or IP address')) + + server_port = forms.IntegerField( + label=_('Server port'), + min_value=0, + max_value=65535, + help_text=_('Server port number')) + + password = forms.CharField( + label=_('Password'), + help_text=_('Password used to encrypt data. ' + 'Must match server password.')) + + method = forms.ChoiceField( + label=_('Method'), + choices=[(x, x) for x in METHODS], + help_text=_('Encryption method. Must match setting on server.')) diff --git a/plinth/modules/shadowsocks/tests/__init__.py b/plinth/modules/shadowsocks/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/shadowsocks/urls.py b/plinth/modules/shadowsocks/urls.py new file mode 100644 index 000000000..a4e2d5e71 --- /dev/null +++ b/plinth/modules/shadowsocks/urls.py @@ -0,0 +1,29 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +URLs for the Shadowsocks module. +""" + +from django.conf.urls import url + +from .views import ShadowsocksServiceView + +urlpatterns = [ + url(r'^apps/shadowsocks/$', ShadowsocksServiceView.as_view(), + name='index'), +] diff --git a/plinth/modules/shadowsocks/views.py b/plinth/modules/shadowsocks/views.py new file mode 100644 index 000000000..e861e0443 --- /dev/null +++ b/plinth/modules/shadowsocks/views.py @@ -0,0 +1,82 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +Plinth module for configuring Shadowsocks. +""" + +import json +from django.contrib import messages +from django.utils.translation import ugettext_lazy as _ + +from .forms import ShadowsocksForm +from plinth import actions +from plinth import views +from plinth.modules import shadowsocks + +SHADOWSOCKS_CONFIG = '/etc/shadowsocks-libev/freedombox.json' + + +class ShadowsocksServiceView(views.ServiceView): + """Configuration view for Shadowsocks local socks5 proxy.""" + service_id = 'shadowsocks' + diagnostics_module_name = 'shadowsocks' + form_class = ShadowsocksForm + description = shadowsocks.description + + def get_initial(self, *args, **kwargs): + """Get initial values for form.""" + try: + configuration = open(SHADOWSOCKS_CONFIG, 'r').read() + status = json.loads(configuration) + except (OSError, json.JSONDecodeError): + status = { + 'server': '', + 'server_port': 8388, + 'password': '', + 'method': 'chacha20-ietf-poly1305', + } + + status['is_enabled'] = self.service.is_enabled() + status['is_running'] = self.service.is_running() + + return status + + def form_valid(self, form): + """Configure Shadowsocks.""" + old_status = form.initial + new_status = form.cleaned_data + + if (old_status['server'] != new_status['server'] or + old_status['server_port'] != new_status['server_port'] or + old_status['password'] != new_status['password'] or + old_status['method'] != new_status['method']): + new_config = { + 'local_address': '::0', + 'local_port': 1080, + 'server': new_status['server'], + 'server_port': new_status['server_port'], + 'password': new_status['password'], + 'method': new_status['method'], + } + + actions.superuser_run( + 'shadowsocks', ['merge-config'], + input=json.dumps(new_config).encode()) + messages.success(self.request, _('Configuration updated')) + + return super().form_valid(form) diff --git a/static/themes/default/icons/shadowsocks.png b/static/themes/default/icons/shadowsocks.png new file mode 100644 index 000000000..ef038ac13 Binary files /dev/null and b/static/themes/default/icons/shadowsocks.png differ