fix(shadowsocks): generate valid ss2022 keys and per-client method for legacy ciphers

The Add Client flow on shadowsocks inbounds was producing xray configs
that failed to start:

- 2022-blake3-* ciphers need a base64-encoded key of an exact byte
  length per cipher. fillProtocolDefaults was assigning a uuid-style
  string, which xray rejects as "bad key". Now the password is
  generated (or replaced if invalid) via random.Base64Bytes(n) sized
  to the chosen cipher.
- Legacy ciphers (aes-256-gcm, chacha20-*, xchacha20-*) require a
  per-client method field in multi-user mode; model.Client has no
  Method, so settings.clients was stored without one and xray failed
  with "unsupported cipher method:". applyShadowsocksClientMethod
  now injects the top-level method into each client on add/update,
  and healShadowsocksClientMethods backfills it at xray-config-build
  time so existing inbounds heal on the next start.
- xray/api.go ssCipherType switch was missing aes-256-gcm, which
  fell through to ss2022 path.
- SSMethods dropdown now offers aes-256-gcm.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-18 00:19:09 +02:00
parent 1d299ac396
commit ef98a932d7
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 160 additions and 6 deletions

View file

@ -17,6 +17,7 @@ export const Protocols = {
}; };
export const SSMethods = { export const SSMethods = {
AES_256_GCM: 'aes-256-gcm',
CHACHA20_POLY1305: 'chacha20-poly1305', CHACHA20_POLY1305: 'chacha20-poly1305',
CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305', CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305', XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',

View file

@ -3,6 +3,7 @@ package random
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64"
"math/big" "math/big"
) )
@ -59,3 +60,14 @@ func Num(n int) int {
} }
return int(r.Int64()) return int(r.Int64())
} }
// Base64Bytes returns n cryptographically-random bytes encoded as standard
// base64 (with padding). Used for ss2022 keys, which xray expects as a
// base64-encoded key of a specific byte length per cipher.
func Base64Bytes(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.StdEncoding.EncodeToString(b)
}

View file

@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -14,6 +15,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/random"
"github.com/mhsanaei/3x-ui/v3/xray" "github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm" "gorm.io/gorm"
@ -360,7 +362,7 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
if getErr != nil { if getErr != nil {
return needRestart, getErr return needRestart, getErr
} }
if err := s.fillProtocolDefaults(&client, inbound.Protocol); err != nil { if err := s.fillProtocolDefaults(&client, inbound); err != nil {
return needRestart, err return needRestart, err
} }
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}}) settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
@ -381,16 +383,21 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
return needRestart, nil return needRestart, nil
} }
func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol) error { func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
switch p { switch ib.Protocol {
case model.VMESS, model.VLESS: case model.VMESS, model.VLESS:
if c.ID == "" { if c.ID == "" {
c.ID = uuid.NewString() c.ID = uuid.NewString()
} }
case model.Trojan, model.Shadowsocks: case model.Trojan:
if c.Password == "" { if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "") c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
} }
case model.Shadowsocks:
method := shadowsocksMethodFromSettings(ib.Settings)
if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
c.Password = randomShadowsocksClientKey(method)
}
case model.Hysteria, model.Hysteria2: case model.Hysteria, model.Hysteria2:
if c.Auth == "" { if c.Auth == "" {
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "") c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
@ -399,6 +406,80 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol)
return nil return nil
} }
// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's
// settings JSON. Returns "" when the field is missing or settings is invalid.
func shadowsocksMethodFromSettings(settings string) string {
if settings == "" {
return ""
}
var m map[string]any
if err := json.Unmarshal([]byte(settings), &m); err != nil {
return ""
}
method, _ := m["method"].(string)
return method
}
// randomShadowsocksClientKey returns a per-client key sized to the cipher.
// The 2022-blake3 ciphers require a base64-encoded key of an exact byte
// length (16 bytes for aes-128-gcm, 32 bytes for aes-256-gcm and
// chacha20-poly1305) — anything else fails with "bad key" on xray start.
// Older ciphers accept arbitrary passwords, so we keep the uuid-style.
func randomShadowsocksClientKey(method string) string {
if n := shadowsocksKeyBytes(method); n > 0 {
return random.Base64Bytes(n)
}
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
// validShadowsocksClientKey reports whether key is acceptable for the cipher.
// For 2022-blake3 it must decode to the exact byte length the cipher needs;
// any other method accepts any non-empty string.
func validShadowsocksClientKey(method, key string) bool {
n := shadowsocksKeyBytes(method)
if n == 0 {
return key != ""
}
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return false
}
return len(decoded) == n
}
func shadowsocksKeyBytes(method string) int {
switch method {
case "2022-blake3-aes-128-gcm":
return 16
case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
return 32
}
return 0
}
// applyShadowsocksClientMethod ensures each client entry carries a "method"
// field for legacy shadowsocks ciphers. xray's multi-user shadowsocks code
// requires a per-client method; an empty/missing field fails with
// "unsupported cipher method:". 2022-blake3 ciphers use the top-level
// method only, so the per-client field must stay absent.
func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
method, _ := settings["method"].(string)
if method == "" || strings.HasPrefix(method, "2022-blake3-") {
return
}
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if existing, _ := cm["method"].(string); existing != "" {
continue
}
cm["method"] = method
clients[i] = cm
}
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) { func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
existing, err := s.GetByID(id) existing, err := s.GetByID(id)
if err != nil { if err != nil {
@ -433,7 +514,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if oldKey == "" { if oldKey == "" {
continue continue
} }
if err := s.fillProtocolDefaults(&updated, inbound.Protocol); err != nil { if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
return needRestart, err return needRestart, err
} }
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}}) settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
@ -530,7 +611,7 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
return needRestart, getErr return needRestart, getErr
} }
copyClient := *clientWire copyClient := *clientWire
if err := s.fillProtocolDefaults(&copyClient, inbound.Protocol); err != nil { if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
return needRestart, err return needRestart, err
} }
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}}) settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
@ -871,6 +952,10 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
return false, err return false, err
} }
if oldInbound.Protocol == model.Shadowsocks {
applyShadowsocksClientMethod(interfaceClients, oldSettings)
}
oldClients := oldSettings["clients"].([]any) oldClients := oldSettings["clients"].([]any)
oldClients = append(oldClients, interfaceClients...) oldClients = append(oldClients, interfaceClients...)
@ -1049,6 +1134,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
interfaceClients[0] = newMap interfaceClients[0] = newMap
} }
} }
if oldInbound.Protocol == model.Shadowsocks {
applyShadowsocksClientMethod(interfaceClients, oldSettings)
}
settingsClients[clientIndex] = interfaceClients[0] settingsClients[clientIndex] = interfaceClients[0]
oldSettings["clients"] = settingsClients oldSettings["clients"] = settingsClients

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"runtime" "runtime"
"strings"
"sync" "sync"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
@ -241,12 +242,62 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
inbound.StreamSettings = string(newStream) inbound.StreamSettings = string(newStream)
} }
if inbound.Protocol == model.Shadowsocks {
if healed, ok := healShadowsocksClientMethods(inbound.Settings); ok {
inbound.Settings = healed
}
}
inboundConfig := inbound.GenXrayInboundConfig() inboundConfig := inbound.GenXrayInboundConfig()
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig) xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
} }
return xrayConfig, nil return xrayConfig, nil
} }
// healShadowsocksClientMethods is the same idea as applyShadowsocksClientMethod
// (see client.go) but applied at xray-config-build time, to backfill the
// per-client method field for legacy shadowsocks inbounds whose clients were
// stored before applyShadowsocksClientMethod existed. Returns the rewritten
// settings string and true when anything actually changed.
func healShadowsocksClientMethods(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
method, _ := parsed["method"].(string)
if method == "" || strings.HasPrefix(method, "2022-blake3-") {
return settings, false
}
clients, ok := parsed["clients"].([]any)
if !ok {
return settings, false
}
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if existing, _ := cm["method"].(string); existing != "" {
continue
}
cm["method"] = method
clients[i] = cm
changed = true
}
if !changed {
return settings, false
}
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
// GetXrayTraffic fetches the current traffic statistics from the running Xray process. // GetXrayTraffic fetches the current traffic statistics from the running Xray process.
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) { func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
if !s.IsXrayRunning() { if !s.IsXrayRunning() {

View file

@ -212,6 +212,8 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
var ssCipherType shadowsocks.CipherType var ssCipherType shadowsocks.CipherType
switch cipher { switch cipher {
case "aes-256-gcm":
ssCipherType = shadowsocks.CipherType_AES_256_GCM
case "chacha20-poly1305", "chacha20-ietf-poly1305": case "chacha20-poly1305", "chacha20-ietf-poly1305":
ssCipherType = shadowsocks.CipherType_CHACHA20_POLY1305 ssCipherType = shadowsocks.CipherType_CHACHA20_POLY1305
case "xchacha20-poly1305", "xchacha20-ietf-poly1305": case "xchacha20-poly1305", "xchacha20-ietf-poly1305":