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