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 <jvalleroy@mailbox.org>
[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 <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
James Valleroy 2023-05-21 18:35:25 -04:00 committed by Sunil Mohan Adapa
parent 1e905d8553
commit e719b1ed49
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
22 changed files with 456 additions and 29 deletions

2
debian/copyright vendored
View File

@ -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) <hzwhuang@gmail.com>
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

View File

@ -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."""

View File

@ -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'))

View File

@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Application manifest for shadowsocks.
Application manifest for Shadowsocks Client.
"""
backup = {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>Shadowsocks server</short>
<description>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.</description>
<port protocol="tcp" port="8388"/>
<port protocol="udp" port="8388"/>
</service>

View File

@ -0,0 +1,3 @@
[Service]
StateDirectory=shadowsocks-libev/%i
DynamicUser=yes

View File

@ -0,0 +1 @@
plinth.modules.shadowsocksserver

View File

@ -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.'))

View File

@ -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']
}

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="512"
height="512"
viewBox="0 0 384 384"
class="mozwebext"
version="1.1"
id="svg74980"
sodipodi:docname="shadowsocks.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata74986">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs74984" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1756"
inkscape:window-height="1255"
id="namedview74982"
showgrid="false"
units="px"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="1.0858896"
inkscape:cx="90"
inkscape:cy="84.666667"
inkscape:window-x="1287"
inkscape:window-y="397"
inkscape:window-maximized="0"
inkscape:current-layer="svg74980" />
<path
d="M 384,9.0045385 314.28744,301.79727 c 0,0 -107.47352,-30.49924 -146.68683,-43.57034 L 295.40696,102.53555 118.22088,242.5416 0,203.61876 Z M 168.47201,291.63086 l 44.73223,14.52345 -46.18457,68.84115 z"
id="path74978"
inkscape:connector-curvature="0"
style="fill:#1a7dc0;stroke-width:2.90468979" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -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

View File

@ -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'),
]

View File

@ -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)

View File

@ -57,6 +57,7 @@ markers = [
"security",
"shaarli",
"shadowsocks",
"shadowsocksserver",
"sharing",
"snapshot",
"ssh",