From e719b1ed495a5853ee6464f45cbae218a6ddd4c5 Mon Sep 17 00:00:00 2001 From: James Valleroy Date: Sun, 21 May 2023 18:35:25 -0400 Subject: [PATCH] shadowsocksserver: Add separate app for Shadowsocks server Closes: #729. Tests: - Install Shadowsocks Server. Install Shadowsocks Client, and set the server to localhost, and set the same password as the server. Use curl to connect to local SOCKS proxy on port 1080 and fetch a website. Signed-off-by: James Valleroy [sunil: Update some docstring comments for shadowsocks clients] [sunil: Use the term Censorship instead of network filters] [sunil: Prevent enabling both apps when setup is re-run] [sunil: Update typehint for a privileged method to be minimal] [sunil: Accept connections from external IPs too] Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- debian/copyright | 2 + plinth/modules/shadowsocks/__init__.py | 21 ++-- plinth/modules/shadowsocks/forms.py | 18 +--- plinth/modules/shadowsocks/manifest.py | 2 +- plinth/modules/shadowsocks/privileged.py | 2 +- .../shadowsocks/tests/test_functional.py | 2 +- plinth/modules/shadowsocks/urls.py | 2 +- plinth/modules/shadowsocks/views.py | 4 +- plinth/modules/shadowsocksserver/__init__.py | 96 ++++++++++++++++++ .../shadowsocks-server-freedombox.xml | 7 ++ .../freedombox.conf | 3 + .../modules-enabled/shadowsocksserver | 1 + plinth/modules/shadowsocksserver/forms.py | 43 ++++++++ plinth/modules/shadowsocksserver/manifest.py | 13 +++ .../modules/shadowsocksserver/privileged.py | 87 ++++++++++++++++ .../static/icons/shadowsocks.png | Bin 0 -> 16487 bytes .../static/icons/shadowsocks.svg | 62 +++++++++++ .../shadowsocksserver/tests/__init__.py | 0 .../tests/test_functional.py | 52 ++++++++++ plinth/modules/shadowsocksserver/urls.py | 13 +++ plinth/modules/shadowsocksserver/views.py | 54 ++++++++++ pyproject.toml | 1 + 22 files changed, 456 insertions(+), 29 deletions(-) create mode 100644 plinth/modules/shadowsocksserver/__init__.py create mode 100644 plinth/modules/shadowsocksserver/data/usr/lib/firewalld/services/shadowsocks-server-freedombox.xml create mode 100644 plinth/modules/shadowsocksserver/data/usr/lib/systemd/system/shadowsocks-libev-server@.service.d/freedombox.conf create mode 100644 plinth/modules/shadowsocksserver/data/usr/share/freedombox/modules-enabled/shadowsocksserver create mode 100644 plinth/modules/shadowsocksserver/forms.py create mode 100644 plinth/modules/shadowsocksserver/manifest.py create mode 100644 plinth/modules/shadowsocksserver/privileged.py create mode 100644 plinth/modules/shadowsocksserver/static/icons/shadowsocks.png create mode 100644 plinth/modules/shadowsocksserver/static/icons/shadowsocks.svg create mode 100644 plinth/modules/shadowsocksserver/tests/__init__.py create mode 100644 plinth/modules/shadowsocksserver/tests/test_functional.py create mode 100644 plinth/modules/shadowsocksserver/urls.py create mode 100644 plinth/modules/shadowsocksserver/views.py diff --git a/debian/copyright b/debian/copyright index 671828216..3cf95666f 100644 --- a/debian/copyright +++ b/debian/copyright @@ -232,11 +232,13 @@ Comment: https://github.com/shaarli/Shaarli/blob/master/doc/md/images/doc-logo.s License: Zlib Files: plinth/modules/shadowsocks/static/icons/shadowsocks.png + plinth/modules/shadowsocksserver/static/icons/shadowsocks.png Copyright: 2014-2018 Symeon Huang (librehat) Comment: https://commons.wikimedia.org/wiki/File:Shadowsocks_logo.png License: LGPL-3+ Files: plinth/modules/shadowsocks/static/icons/shadowsocks.svg + plinth/modules/shadowsocksserver/static/icons/shadowsocks.svg Copyright: Clowwindy Comment: https://commons.wikimedia.org/wiki/File:Shadowsocks-Logo.svg License: Apache-2.0 diff --git a/plinth/modules/shadowsocks/__init__.py b/plinth/modules/shadowsocks/__init__.py index e8c7b75a7..9707a87eb 100644 --- a/plinth/modules/shadowsocks/__init__.py +++ b/plinth/modules/shadowsocks/__init__.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""FreedomBox app to configure Shadowsocks.""" +"""FreedomBox app to configure Shadowsocks Client.""" from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -15,9 +15,13 @@ from plinth.utils import format_lazy from . import manifest, privileged _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.'), + _('Shadowsocks is a tool for securely forwarding network requests to a' + ' remote server. It consists of two parts: (1) a Shadowsocks server,' + ' and (2) a Shadowsocks client with a SOCKS5 proxy.'), + _('Shadowsocks can be used to bypass Internet filtering and ' + 'censorship. This requires that the Shadowsocks server is in a ' + 'location where it can freely access the Internet, without ' + 'filtering.'), format_lazy( _('Your {box_name} can run a Shadowsocks client, that can connect to ' 'a Shadowsocks server. It will also run a SOCKS5 proxy. Local ' @@ -30,7 +34,7 @@ _description = [ class ShadowsocksApp(app_module.App): - """FreedomBox app for Shadowsocks.""" + """FreedomBox app for Shadowsocks Client.""" app_id = 'shadowsocks' @@ -43,9 +47,9 @@ class ShadowsocksApp(app_module.App): super().__init__() info = app_module.Info(app_id=self.app_id, version=self._version, - name=_('Shadowsocks'), + name=_('Shadowsocks Client'), icon_filename='shadowsocks', - short_description=_('Socks5 Proxy'), + short_description=_('Bypass Censorship'), description=_description, manual_page='Shadowsocks') self.add(info) @@ -83,7 +87,8 @@ class ShadowsocksApp(app_module.App): """Install and configure the app.""" super().setup(old_version) privileged.setup() - self.enable() + if not old_version: + self.enable() def uninstall(self): """De-configure and uninstall the app.""" diff --git a/plinth/modules/shadowsocks/forms.py b/plinth/modules/shadowsocks/forms.py index 102b77baf..076864889 100644 --- a/plinth/modules/shadowsocks/forms.py +++ b/plinth/modules/shadowsocks/forms.py @@ -1,22 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""FreedomBox app for configuring Shadowsocks.""" +"""FreedomBox app for configuring Shadowsocks Client.""" from django import forms from django.utils.translation import gettext_lazy as _ -from plinth.utils import format_lazy - -METHODS = [('chacha20-ietf-poly1305', - format_lazy('chacha20-ietf-poly1305 ({})', _('Recommended'))), - ('aes-256-gcm', format_lazy('aes-256-gcm ({})', _('Recommended'))), - ('aes-192-gcm', 'aes-192-gcm'), ('aes-128-gcm', 'aes-128-gcm'), - ('aes-128-ctr', 'aes-128-ctr'), ('aes-192-ctr', 'aes-192-ctr'), - ('aes-256-ctr', 'aes-256-ctr'), ('aes-128-cfb', 'aes-128-cfb'), - ('aes-192-cfb', 'aes-192-cfb'), ('aes-256-cfb', 'aes-256-cfb'), - ('camellia-128-cfb', 'camellia-128-cfb'), - ('camellia-192-cfb', 'camellia-192-cfb'), - ('camellia-256-cfb', 'camellia-256-cfb'), - ('chacha20-ietf', 'chacha20-ietf')] +from plinth.modules.shadowsocksserver.forms import METHODS class TrimmedCharField(forms.CharField): @@ -31,7 +19,7 @@ class TrimmedCharField(forms.CharField): class ShadowsocksForm(forms.Form): - """Shadowsocks configuration form.""" + """Shadowsocks Client configuration form.""" server = TrimmedCharField(label=_('Server'), help_text=_('Server hostname or IP address')) diff --git a/plinth/modules/shadowsocks/manifest.py b/plinth/modules/shadowsocks/manifest.py index 33cac4e4f..52d8f00e6 100644 --- a/plinth/modules/shadowsocks/manifest.py +++ b/plinth/modules/shadowsocks/manifest.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -Application manifest for shadowsocks. +Application manifest for Shadowsocks Client. """ backup = { diff --git a/plinth/modules/shadowsocks/privileged.py b/plinth/modules/shadowsocks/privileged.py index 88d0509a1..4d3075654 100644 --- a/plinth/modules/shadowsocks/privileged.py +++ b/plinth/modules/shadowsocks/privileged.py @@ -87,7 +87,7 @@ def _merge_config(config): @privileged def merge_config(config: dict[str, Union[int, str]]): - """Configure Shadowsocks.""" + """Configure Shadowsocks Client.""" _merge_config(config) # Don't try_restart because initial configuration may not be valid so diff --git a/plinth/modules/shadowsocks/tests/test_functional.py b/plinth/modules/shadowsocks/tests/test_functional.py index 7bcdbb50b..87bcda6e0 100644 --- a/plinth/modules/shadowsocks/tests/test_functional.py +++ b/plinth/modules/shadowsocks/tests/test_functional.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -Functional, browser based tests for shadowsocks app. +Functional, browser based tests for Shadowsocks Client app. """ import pytest diff --git a/plinth/modules/shadowsocks/urls.py b/plinth/modules/shadowsocks/urls.py index bc776c74a..62036a54f 100644 --- a/plinth/modules/shadowsocks/urls.py +++ b/plinth/modules/shadowsocks/urls.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -URLs for the Shadowsocks module. +URLs for the Shadowsocks Clients. """ from django.urls import re_path diff --git a/plinth/modules/shadowsocks/views.py b/plinth/modules/shadowsocks/views.py index ad2deda1b..b0a9c955a 100644 --- a/plinth/modules/shadowsocks/views.py +++ b/plinth/modules/shadowsocks/views.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""FreedomBox app for configuring Shadowsocks.""" +"""FreedomBox app for configuring Shadowsocks Client.""" from django.contrib import messages from django.utils.translation import gettext_lazy as _ @@ -11,7 +11,7 @@ from .forms import ShadowsocksForm class ShadowsocksAppView(views.AppView): - """Configuration view for Shadowsocks local socks5 proxy.""" + """Configuration view for Shadowsocks Client and SOCKS5 proxy.""" app_id = 'shadowsocks' form_class = ShadowsocksForm diff --git a/plinth/modules/shadowsocksserver/__init__.py b/plinth/modules/shadowsocksserver/__init__.py new file mode 100644 index 000000000..62a27830b --- /dev/null +++ b/plinth/modules/shadowsocksserver/__init__.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""FreedomBox app to configure Shadowsocks Server.""" + +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.daemon import Daemon +from plinth.modules.backups.components import BackupRestore +from plinth.modules.firewall.components import Firewall +from plinth.package import Packages +from plinth.utils import format_lazy + +from . import manifest, privileged + +_description = [ + _('Shadowsocks is a tool for securely forwarding network requests to a' + ' remote server. It consists of two parts: (1) a Shadowsocks server,' + ' and (2) a Shadowsocks client with a SOCKS5 proxy.'), + _('Shadowsocks can be used to bypass Internet filtering and ' + 'censorship. This requires that the Shadowsocks server is in a ' + 'location where it can freely access the Internet, without ' + 'filtering.'), + format_lazy( + _('Your {box_name} can run a Shadowsocks server, that allows ' + 'Shadowsocks clients to connect to it. Clients\' data will be ' + 'encrypted and proxied through this server.'), + box_name=_(cfg.box_name)), +] + + +class ShadowsocksServerApp(app_module.App): + """FreedomBox app for Shadowsocks Server.""" + + app_id = 'shadowsocksserver' + + _version = 1 + + DAEMON = 'shadowsocks-libev-server@fbxserver' + + def __init__(self): + """Create components for the app.""" + super().__init__() + + info = app_module.Info( + app_id=self.app_id, version=self._version, + name=_('Shadowsocks Server'), icon_filename='shadowsocks', + short_description=_('Help Others Bypass Censorship'), + description=_description, manual_page='ShadowsocksServer') + self.add(info) + + menu_item = menu.Menu('menu-shadowsocks-server', info.name, + info.short_description, info.icon_filename, + 'shadowsocksserver:index', + parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut( + 'shortcut-shadowsocks-server', info.name, + short_description=info.short_description, icon=info.icon_filename, + description=info.description, manual_page=info.manual_page, + configure_url=reverse_lazy('shadowsocksserver:index'), + login_required=True) + self.add(shortcut) + + packages = Packages('packages-shadowsocks-server', + ['shadowsocks-libev']) + self.add(packages) + + firewall = Firewall('firewall-shadowsocks-server', info.name, + ports=['shadowsocks-server-freedombox'], + is_external=True) + self.add(firewall) + + daemon = Daemon( + 'daemon-shadowsocks-server', self.DAEMON, + listen_ports=[(8388, 'tcp4'), (8388, 'tcp6'), (8388, 'udp4'), + (8388, 'udp6')]) + self.add(daemon) + + backup_restore = BackupRestore('backup-restore-shadowsocks-server', + **manifest.backup) + self.add(backup_restore) + + def setup(self, old_version): + """Install and configure the app.""" + super().setup(old_version) + privileged.setup() + if not old_version: + self.enable() + + def uninstall(self): + """De-configure and uninstall the app.""" + super().uninstall() + privileged.uninstall() diff --git a/plinth/modules/shadowsocksserver/data/usr/lib/firewalld/services/shadowsocks-server-freedombox.xml b/plinth/modules/shadowsocksserver/data/usr/lib/firewalld/services/shadowsocks-server-freedombox.xml new file mode 100644 index 000000000..dcb79bb58 --- /dev/null +++ b/plinth/modules/shadowsocksserver/data/usr/lib/firewalld/services/shadowsocks-server-freedombox.xml @@ -0,0 +1,7 @@ + + + Shadowsocks server + Shadowsocks is a lightweight and secure SOCKS5 proxy, designed to protect your Internet traffic. Enable this service if you are running a Shadowsocks server, to provide secured tunnel service. + + + diff --git a/plinth/modules/shadowsocksserver/data/usr/lib/systemd/system/shadowsocks-libev-server@.service.d/freedombox.conf b/plinth/modules/shadowsocksserver/data/usr/lib/systemd/system/shadowsocks-libev-server@.service.d/freedombox.conf new file mode 100644 index 000000000..ae1330adf --- /dev/null +++ b/plinth/modules/shadowsocksserver/data/usr/lib/systemd/system/shadowsocks-libev-server@.service.d/freedombox.conf @@ -0,0 +1,3 @@ +[Service] +StateDirectory=shadowsocks-libev/%i +DynamicUser=yes diff --git a/plinth/modules/shadowsocksserver/data/usr/share/freedombox/modules-enabled/shadowsocksserver b/plinth/modules/shadowsocksserver/data/usr/share/freedombox/modules-enabled/shadowsocksserver new file mode 100644 index 000000000..dd09e3731 --- /dev/null +++ b/plinth/modules/shadowsocksserver/data/usr/share/freedombox/modules-enabled/shadowsocksserver @@ -0,0 +1 @@ +plinth.modules.shadowsocksserver diff --git a/plinth/modules/shadowsocksserver/forms.py b/plinth/modules/shadowsocksserver/forms.py new file mode 100644 index 000000000..e4c993850 --- /dev/null +++ b/plinth/modules/shadowsocksserver/forms.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""FreedomBox app for configuring Shadowsocks Server.""" + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from plinth.utils import format_lazy + +METHODS = [('chacha20-ietf-poly1305', + format_lazy('chacha20-ietf-poly1305 ({})', _('Recommended'))), + ('aes-256-gcm', format_lazy('aes-256-gcm ({})', _('Recommended'))), + ('aes-192-gcm', 'aes-192-gcm'), ('aes-128-gcm', 'aes-128-gcm'), + ('aes-128-ctr', 'aes-128-ctr'), ('aes-192-ctr', 'aes-192-ctr'), + ('aes-256-ctr', 'aes-256-ctr'), ('aes-128-cfb', 'aes-128-cfb'), + ('aes-192-cfb', 'aes-192-cfb'), ('aes-256-cfb', 'aes-256-cfb'), + ('camellia-128-cfb', 'camellia-128-cfb'), + ('camellia-192-cfb', 'camellia-192-cfb'), + ('camellia-256-cfb', 'camellia-256-cfb'), + ('chacha20-ietf', 'chacha20-ietf')] + + +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().clean(value) + + +class ShadowsocksServerForm(forms.Form): + """Shadowsocks Server configuration form.""" + + password = forms.CharField( + label=_('Password'), + help_text=_('Password used to encrypt data. Clients must use the ' + 'same password.')) + + method = forms.ChoiceField( + label=_('Method'), choices=METHODS, + help_text=_('Encryption method. Clients must use the same setting.')) diff --git a/plinth/modules/shadowsocksserver/manifest.py b/plinth/modules/shadowsocksserver/manifest.py new file mode 100644 index 000000000..c18b96fc4 --- /dev/null +++ b/plinth/modules/shadowsocksserver/manifest.py @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Application manifest for Shadowsocks Server. +""" + +backup = { + 'secrets': { + 'files': [ + '/var/lib/private/shadowsocks-libev/fbxserver/fbxserver.json' + ] + }, + 'services': ['shadowsocks-libev-server@fbxserver'] +} diff --git a/plinth/modules/shadowsocksserver/privileged.py b/plinth/modules/shadowsocksserver/privileged.py new file mode 100644 index 000000000..1e51bdccd --- /dev/null +++ b/plinth/modules/shadowsocksserver/privileged.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure Shadowsocks Server.""" + +import json +import os +import pathlib +import random +import string +from typing import Union + +from plinth import action_utils +from plinth.actions import privileged + +SHADOWSOCKS_CONFIG_SYMLINK = '/etc/shadowsocks-libev/fbxserver.json' +SHADOWSOCKS_CONFIG_ACTUAL = \ + '/var/lib/private/shadowsocks-libev/fbxserver/fbxserver.json' + + +@privileged +def setup(): + """Perform initial setup steps.""" + # Disable the default service, and use the templated service instead, so + # that the configuration can be customized. + action_utils.service_disable('shadowsocks-libev') + + os.makedirs('/var/lib/private/shadowsocks-libev/fbxserver/', exist_ok=True) + + if not os.path.islink(SHADOWSOCKS_CONFIG_SYMLINK): + os.symlink(SHADOWSOCKS_CONFIG_ACTUAL, SHADOWSOCKS_CONFIG_SYMLINK) + + if not os.path.isfile(SHADOWSOCKS_CONFIG_ACTUAL): + password = ''.join( + random.choice(string.ascii_letters) for _ in range(12)) + initial_config = { + 'server': ['::0', '0.0.0.0'], # As recommended in man page + 'mode': 'tcp_and_udp', + 'server_port': 8388, + 'password': password, + 'timeout': 86400, + 'method': 'chacha20-ietf-poly1305' + } + _merge_config(initial_config) + + from plinth.modules.shadowsocksserver import ShadowsocksServerApp + if action_utils.service_is_enabled(ShadowsocksServerApp.DAEMON): + action_utils.service_restart(ShadowsocksServerApp.DAEMON) + + +@privileged +def get_config() -> dict[str, Union[int, str]]: + """Read and print Shadowsocks Server configuration.""" + config = open(SHADOWSOCKS_CONFIG_SYMLINK, 'r', encoding='utf-8').read() + return json.loads(config) + + +def _merge_config(config): + """Write merged configuration into file.""" + try: + current_config = open(SHADOWSOCKS_CONFIG_SYMLINK, 'r', + encoding='utf-8').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_SYMLINK, 'w', encoding='utf-8').write(new_config) + + +@privileged +def merge_config(config: dict[str, str]): + """Configure Shadowsocks Server.""" + _merge_config(config) + + # Don't try_restart because initial configuration may not be valid so + # shadowsocks will not be running even when enabled. + from . import ShadowsocksServerApp + if action_utils.service_is_enabled(ShadowsocksServerApp.DAEMON): + action_utils.service_restart(ShadowsocksServerApp.DAEMON) + + +@privileged +def uninstall(): + """Remove configuration files.""" + for path in SHADOWSOCKS_CONFIG_SYMLINK, SHADOWSOCKS_CONFIG_ACTUAL: + pathlib.Path(path).unlink(missing_ok=True) diff --git a/plinth/modules/shadowsocksserver/static/icons/shadowsocks.png b/plinth/modules/shadowsocksserver/static/icons/shadowsocks.png new file mode 100644 index 0000000000000000000000000000000000000000..ef038ac138703a9aace2df15a7cbdae9d25aeea0 GIT binary patch literal 16487 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 Uw;|=he;@!ME2$(=BW4WxA7O8|tpET3 literal 0 HcmV?d00001 diff --git a/plinth/modules/shadowsocksserver/static/icons/shadowsocks.svg b/plinth/modules/shadowsocksserver/static/icons/shadowsocks.svg new file mode 100644 index 000000000..8a94454f4 --- /dev/null +++ b/plinth/modules/shadowsocksserver/static/icons/shadowsocks.svg @@ -0,0 +1,62 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/plinth/modules/shadowsocksserver/tests/__init__.py b/plinth/modules/shadowsocksserver/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/shadowsocksserver/tests/test_functional.py b/plinth/modules/shadowsocksserver/tests/test_functional.py new file mode 100644 index 000000000..c270bc163 --- /dev/null +++ b/plinth/modules/shadowsocksserver/tests/test_functional.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Functional, browser based tests for Shadowsocks Server app. +""" + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.shadowsocksserver] + + +class TestShadowsocksServerApp(functional.BaseAppTests): + app_name = 'shadowsocksserver' + has_service = True + has_web = False + + @pytest.fixture(scope='class', autouse=True) + def fixture_setup(self, session_browser): + """Setup the app.""" + functional.login(session_browser) + functional.install(session_browser, 'shadowsocksserver') + _configure(session_browser, 'fakepassword') + + @pytest.mark.backups + def test_backup_restore(self, session_browser): + """Test backup and restore of configuration.""" + _configure(session_browser, 'beforebackup123') + functional.backup_create(session_browser, 'shadowsocksserver', + 'test_shadowsocksserver') + + _configure(session_browser, 'afterbackup123') + functional.backup_restore(session_browser, 'shadowsocksserver', + 'test_shadowsocksserver') + + assert functional.service_is_running(session_browser, + 'shadowsocksserver') + assert _get_configuration(session_browser) == 'beforebackup123' + + +def _configure(browser, password): + """Configure Shadowsocks Server with given details.""" + functional.visit(browser, '/plinth/apps/shadowsocksserver/') + browser.find_by_id('id_password').fill(password) + functional.submit(browser, form_class='form-configuration') + + +def _get_configuration(browser): + """Return the password currently configured in Shadowsocks Server.""" + functional.visit(browser, '/plinth/apps/shadowsocksserver/') + password = browser.find_by_id('id_password').value + return password diff --git a/plinth/modules/shadowsocksserver/urls.py b/plinth/modules/shadowsocksserver/urls.py new file mode 100644 index 000000000..68da326f4 --- /dev/null +++ b/plinth/modules/shadowsocksserver/urls.py @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +URLs for the Shadowsocks Server module. +""" + +from django.urls import re_path + +from .views import ShadowsocksServerAppView + +urlpatterns = [ + re_path(r'^apps/shadowsocksserver/$', ShadowsocksServerAppView.as_view(), + name='index'), +] diff --git a/plinth/modules/shadowsocksserver/views.py b/plinth/modules/shadowsocksserver/views.py new file mode 100644 index 000000000..7265eaa8b --- /dev/null +++ b/plinth/modules/shadowsocksserver/views.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""FreedomBox app for configuring Shadowsocks Server.""" + +import random +import string + +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ + +from plinth import views + +from . import privileged +from .forms import ShadowsocksServerForm + + +class ShadowsocksServerAppView(views.AppView): + """Configuration view for Shadowsocks Server.""" + + app_id = 'shadowsocksserver' + form_class = ShadowsocksServerForm + + def get_initial(self, *args, **kwargs): + """Get initial values for form.""" + status = super().get_initial() + try: + status.update(privileged.get_config()) + except Exception: + # If we cannot read the configuration for some reason, generate a + # new random password. + password = ''.join( + random.choice(string.ascii_letters) for _ in range(12)) + status.update({ + 'password': password, + 'method': 'chacha20-ietf-poly1305', + }) + + return status + + def form_valid(self, form): + """Configure Shadowsocks Server.""" + old_status = form.initial + new_status = form.cleaned_data + + if old_status['password'] != new_status['password'] or \ + old_status['method'] != new_status['method']: + new_config = { + 'password': new_status['password'], + 'method': new_status['method'], + } + + privileged.merge_config(new_config) + messages.success(self.request, _('Configuration updated')) + + return super().form_valid(form) diff --git a/pyproject.toml b/pyproject.toml index 0af5e8616..fb28b3f97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ markers = [ "security", "shaarli", "shadowsocks", + "shadowsocksserver", "sharing", "snapshot", "ssh",