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 000000000..ef038ac13 Binary files /dev/null and b/plinth/modules/shadowsocksserver/static/icons/shadowsocks.png differ 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",