Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,39 @@ Shadowsocks Accounts` and assign the existing nodes to them.
After a few seconds, the created user ports should be available to your
Shadowsocks client.

### 3.1. Shadowsocks-2022 (SS-2022) with the rust edition

The classic ciphers above run on the `libev` server edition. shadowsocks-libev does
**not** implement the Shadowsocks-2022 (SIP022 AEAD-2022) ciphers
(`2022-blake3-aes-256-gcm` and friends), which are highly censorship-resistant.

For SS-2022, use the `rust` server edition, backed by the
[alexzhangs/shadowsocks-rust](https://github.com/alexzhangs/shadowsocks-rust) image. Its
`ssmanager` speaks the same multi-user Manager API, so everything else works unchanged.

1. Run the rust manager instead of the libev one:

```sh
MGR_PORT=6001
ENCRYPT=2022-blake3-aes-256-gcm
docker run -d -p $MGR_PORT:$MGR_PORT/UDP \
--network ssm-network --name ssm-ss-rust alexzhangs/shadowsocks-rust \
ssmanager --manager-address 0.0.0.0:$MGR_PORT -m $ENCRYPT -s 0.0.0.0 -U
```

2. Add the Node, then on its Shadowsocks Manager set **Server edition** to `rust` and
**Encrypt** to a SS-2022 method (e.g. `2022-blake3-aes-256-gcm`).

3. Create accounts as usual. For SS-2022 the account **password must be a Base64 PSK** of
the cipher's key size (32 bytes for the `aes-256`/`chacha20` methods, 16 bytes for
`aes-128`). Select the accounts and run the admin action **"Generate Shadowsocks-2022
password (PSK)"** to fill conforming PSKs automatically; the portal also rejects a
non-conforming password for a SS-2022 node with a clear error.

When deploying through [aws-cfn-vpn](https://github.com/alexzhangs/aws-cfn-vpn), set the
stack parameter `SSEdition=rust` together with a SS-2022 `SSEncrypt` value, and the node
provisions and self-registers as a rust/SS-2022 node automatically.


## 4. Sendmail (Optional)

Expand Down
19 changes: 18 additions & 1 deletion shadowsocks_manager/shadowsocks/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from import_export.admin import ImportExportModelAdmin
from admin_lazy_load import LazyLoadAdminMixin

from . import ciphers
from .models import Config, Node, Account, NodeAccount, SSManager


Expand Down Expand Up @@ -219,5 +220,21 @@ def add_all_nodes(self, request, queryset):

add_all_nodes.short_description = 'Add All Nodes to Selected Shadowsocks Accounts'

actions = (toggle_active, notify, add_all_nodes,)
def generate_ss2022_password(self, request, queryset):
for obj in queryset:
# Use the cipher of an assigned Shadowsocks-2022 node if there is one,
# otherwise default to the recommended 32-byte 2022-blake3-aes-256-gcm.
method = '2022-blake3-aes-256-gcm'
for na in obj.nodes_ref.all():
ssmanager = na.node.ssmanager
if ssmanager and ciphers.is_2022(ssmanager.encrypt):
method = ssmanager.encrypt
break
obj.password = ciphers.generate_password(method)
obj.save()
messages.info(request, '{}: generated a {} PSK.'.format(obj, method))

generate_ss2022_password.short_description = 'Generate Shadowsocks-2022 password (PSK) for Selected Shadowsocks Accounts'

actions = (toggle_active, notify, add_all_nodes, generate_ss2022_password,)
resource_class = AccountResource
72 changes: 72 additions & 0 deletions shadowsocks_manager/shadowsocks/ciphers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""
Shadowsocks cipher (encrypt method) metadata and helpers.

Single source of truth for:
* which methods are Shadowsocks-2022 (SIP022 AEAD-2022) ciphers and their key sizes
* generating a conforming password for a method
* validating that a password conforms to a method

For SS-2022 ciphers the password MUST be a Base64-encoded key (PSK) of exactly the
cipher's key size; arbitrary passwords are rejected by the server. shadowsocks-libev
does not implement the SS-2022 ciphers -- they require the `rust` server edition
(shadowsocks-rust), see https://github.com/alexzhangs/shadowsocks-rust .
"""

import os
import base64

# SS-2022 (SIP022) methods -> required key size in bytes.
AEAD_2022_METHODS = {
'2022-blake3-aes-128-gcm': 16,
'2022-blake3-aes-256-gcm': 32,
'2022-blake3-chacha20-poly1305': 32,
}

# Length for legacy (non-2022) random passwords.
DEFAULT_PASSWORD_LENGTH = 16


def is_2022(method):
"""Return True if `method` is a Shadowsocks-2022 (SIP022) cipher."""
return method in AEAD_2022_METHODS


def key_size(method):
"""Return the required key size in bytes for a SS-2022 `method`, else None."""
return AEAD_2022_METHODS.get(method)


def generate_password(method=None):
"""
Generate a password suitable for `method`.

For SS-2022 ciphers, returns a Base64-encoded random key (PSK) of exactly the
cipher's key size -- equivalent to `ssservice genkey -m <method>`. For any other
method (or None), returns a random alphanumeric string.
"""
size = key_size(method)
if size is not None:
return base64.b64encode(os.urandom(size)).decode('ascii')

import random
import string
return ''.join(random.choices(string.ascii_letters + string.digits,
k=DEFAULT_PASSWORD_LENGTH))


def is_valid_password(method, password):
"""
Return True if `password` is valid for `method`.

For SS-2022 ciphers, the password must be a Base64 string that decodes to exactly
the cipher's key size. For any other method, any non-empty password is accepted.
"""
size = key_size(method)
if size is None:
return bool(password)
try:
raw = base64.b64decode(password, validate=True)
except Exception:
return False
return len(raw) == size
35 changes: 31 additions & 4 deletions shadowsocks_manager/shadowsocks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from notification.models import Template, Notify
from domain.models import Record

from . import ciphers


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -120,6 +122,20 @@ def clean(self):
raise ValidationError(_('Port number must be in the range of Config: '
'port_begin(%s) and port_end(%s)' % (config.port_begin, config.port_end)))

# For accounts assigned to any node using a Shadowsocks-2022 cipher, the
# password must be a valid Base64 PSK of that cipher's key size; otherwise
# the rust server silently rejects the port. Validate here for a clear error.
# (Only enforceable once the account is saved and has node assignments.)
if self.pk:
for na in self.nodes_ref.all():
ssmanager = na.node.ssmanager
if ssmanager and ciphers.is_2022(ssmanager.encrypt) \
and not ciphers.is_valid_password(ssmanager.encrypt, self.password):
raise ValidationError({'password': [_('Node "%s" uses the Shadowsocks-2022 '
'cipher "%s"; the password must be a Base64 key of %s bytes. Use the '
'"Generate Shadowsocks-2022 password" account action.'
% (na.node.name, ssmanager.encrypt, ciphers.key_size(ssmanager.encrypt)))]})

def save(self, *args, **kwargs):
ret = super(Account, self).save(*args, **kwargs)

Expand Down Expand Up @@ -542,10 +558,12 @@ class InterfaceList(enum.Enum):
class ServerEditionList(enum.Enum):
LIBEV = 1
PYTHON = 2
RUST = 3

__labels__ = {
LIBEV: "libev",
PYTHON: "python",
RUST: "rust",
}


Expand All @@ -561,9 +579,12 @@ class SSManager(models.Model):
'aes-128-cfb, aes-192-cfb, aes-256-cfb, aes-128-ctr, aes-192-ctr, aes-256-ctr, '
'camellia-128-cfb, camellia-192-cfb, camellia-256-cfb, bf-cfb, chacha20-ietf-poly1305, '
'xchacha20-ietf-poly1305, salsa20, chacha20 and chacha20-ietf. '
'Shadowsocks-2022 ciphers (require the rust server edition): '
'2022-blake3-aes-128-gcm, 2022-blake3-aes-256-gcm, 2022-blake3-chacha20-poly1305. '
'The changes made here will not affect the plugin status on server.')
server_edition = enum.EnumField(ServerEditionList, default=ServerEditionList.LIBEV,
help_text='The Shadowsocks server edition. The libev edition is recommended.')
help_text='The Shadowsocks server edition. The libev edition is recommended for the '
'classic ciphers; the rust edition is required for the Shadowsocks-2022 ciphers.')
is_v2ray_enabled = models.BooleanField(default=False,
help_text='Whether the v2ray-plugin is enabled for Shadowsocks server. The changes made here will not '
'affect the plugin status on server.')
Expand All @@ -586,6 +607,10 @@ def clean(self):
raise ValidationError({
self.node.get_ip_field_by_interface(self.interface):
[_('There is no IP address set for selected interface on the node.')]})
# Shadowsocks-2022 ciphers are only implemented by the rust server edition.
if ciphers.is_2022(self.encrypt) and self.server_edition != ServerEditionList.RUST:
raise ValidationError({'encrypt': [_('The Shadowsocks-2022 cipher "%s" requires the '
'rust server edition.' % self.encrypt)]})

@property
def _ip(self):
Expand Down Expand Up @@ -664,10 +689,10 @@ def _list(self):
"""
Manager API command: `list`.
List all users with password.
Works only for libev edition.
Works for the libev and rust editions.
This is an undocumented Shadowsocks Manager Command, but works.
"""
if self.server_edition == ServerEditionList.LIBEV:
if self.server_edition in (ServerEditionList.LIBEV, ServerEditionList.RUST):
command = 'list'
return self.call(command, read=True)

Expand Down Expand Up @@ -779,7 +804,9 @@ def is_port_created(self, port):
"""
items = self.list_ex()
if isinstance(items, list):
return any(item['server_port'] == str(port) for item in items)
# libev returns server_port as a string ("8381"); rust returns it as an
# integer (8381). Compare as strings so both editions match.
return any(str(item['server_port']) == str(port) for item in items)
else:
return None

Expand Down
Loading