diff --git a/internal/database/db.go b/internal/database/db.go index 4e555ce35..079f1445a 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -181,6 +181,9 @@ func initModels() error { if err := migrateShadowsocksRemovedCiphers(); err != nil { return err } + if err := repairShadowsocks2022ServerKeys(); err != nil { + return err + } if err := migrateVmessRemovedSecurities(); err != nil { return err } @@ -987,6 +990,37 @@ func migrateShadowsocksRemovedCiphers() error { return nil } +// repairShadowsocks2022ServerKeys replaces a stored server key xray-core cannot +// parse; that inbound never worked, as the core refuses to start with it. +func repairShadowsocks2022ServerKeys() error { + var inbounds []model.Inbound + if err := db.Where("protocol = ?", model.Shadowsocks).Find(&inbounds).Error; err != nil { + return err + } + for _, inbound := range inbounds { + var settings map[string]any + if json.Unmarshal([]byte(inbound.Settings), &settings) != nil { + continue + } + method, _ := settings["method"].(string) + n := xray.Shadowsocks2022KeyBytes(method) + if key, _ := settings["password"].(string); n == 0 || xray.ValidShadowsocks2022Key(method, key) { + continue + } + settings["password"] = random.Base64Bytes(n) + newSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id). + Update("settings", string(newSettings)).Error; err != nil { + return err + } + log.Printf("Inbound %q: replaced a %s server key of the wrong size; its share links changed", inbound.Tag, method) + } + return nil +} + // migrateVmessRemovedSecurities rewrites the vmess "none"/"zero" security // values that xray-core v26.7.11 removed to "auto" (what the core now treats // them as), on both the clients column and each vmess inbound's settings. diff --git a/internal/database/shadowsocks_server_key_migration_test.go b/internal/database/shadowsocks_server_key_migration_test.go new file mode 100644 index 000000000..1a6650f1f --- /dev/null +++ b/internal/database/shadowsocks_server_key_migration_test.go @@ -0,0 +1,69 @@ +package database + +import ( + "encoding/base64" + "encoding/json" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// A stored Shadowsocks-2022 server key of the wrong size keeps xray-core from +// starting at all, so startup must replace it and leave every valid key alone. +func TestRepairShadowsocks2022ServerKeys(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + key16 := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef")) + key32 := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + rows := []struct { + tag, settings, wantKey string + wantBytes int + }{ + {"oversized", `{"method":"2022-blake3-aes-128-gcm","password":"` + key32 + `","clients":[]}`, "", 16}, + {"undersized", `{"method":"2022-blake3-chacha20-poly1305","password":"` + key16 + `","clients":[]}`, "", 32}, + {"valid", `{"method":"2022-blake3-aes-256-gcm","password":"` + key32 + `","clients":[]}`, key32, 32}, + {"raw valid", `{"method":"2022-blake3-aes-128-gcm","password":"0123456789abcd-!","clients":[]}`, "0123456789abcd-!", 0}, + {"legacy cipher", `{"method":"aes-256-gcm","password":"short","clients":[]}`, "short", 0}, + } + for i, r := range rows { + ib := model.Inbound{UserId: 1, Port: 31100 + i, Protocol: model.Shadowsocks, Tag: r.tag, Settings: r.settings} + if err := GetDB().Create(&ib).Error; err != nil { + t.Fatalf("create %s: %v", r.tag, err) + } + } + + if err := repairShadowsocks2022ServerKeys(); err != nil { + t.Fatalf("repairShadowsocks2022ServerKeys: %v", err) + } + + for _, r := range rows { + t.Run(r.tag, func(t *testing.T) { + var ib model.Inbound + if err := GetDB().Where("tag = ?", r.tag).First(&ib).Error; err != nil { + t.Fatal(err) + } + var got struct { + Password string `json:"password"` + } + if err := json.Unmarshal([]byte(ib.Settings), &got); err != nil { + t.Fatal(err) + } + if r.wantKey != "" { + if got.Password != r.wantKey { + t.Errorf("key changed to %q, want it kept as %q", got.Password, r.wantKey) + } + return + } + decoded, err := base64.StdEncoding.DecodeString(got.Password) + if err != nil || len(decoded) != r.wantBytes { + t.Errorf("repaired key %q: %d bytes (err %v), want %d", got.Password, len(decoded), err, r.wantBytes) + } + }) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index e8fe14629..c9e59ed7d 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -509,6 +509,29 @@ func shadowsocksKeyBytes(method string) int { return 0 } +// validateShadowsocks2022ServerKey refuses a server key xray-core cannot parse: +// the core then fails to build the inbound and does not start at all. +func validateShadowsocks2022ServerKey(protocol model.Protocol, settings string) error { + if protocol != model.Shadowsocks { + return nil + } + var parsed struct { + Method string `json:"method"` + Password string `json:"password"` + } + if json.Unmarshal([]byte(settings), &parsed) != nil { + return nil + } + n := xray.Shadowsocks2022KeyBytes(parsed.Method) + if n == 0 { + return nil + } + if !xray.ValidShadowsocks2022Key(parsed.Method, parsed.Password) { + return common.NewErrorf("shadowsocks inbound password: %s needs a %d-byte key, base64 encoded", parsed.Method, n) + } + return nil +} + // normalizeShadowsocksClientKeys rewrites any Shadowsocks-2022 client password // whose decoded length no longer matches settings.method, which happens after the // inbound method is switched between ciphers of different key sizes (e.g. diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index a84938c3d..cca391618 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1178,6 +1178,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil { return inbound, false, err } + if err := validateShadowsocks2022ServerKey(inbound.Protocol, inbound.Settings); err != nil { + return inbound, false, err + } s.normalizeMtprotoSecret(inbound) if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil { return inbound, false, err @@ -1823,6 +1826,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil { return inbound, false, err } + if err := validateShadowsocks2022ServerKey(inbound.Protocol, inbound.Settings); err != nil { + return inbound, false, err + } s.normalizeMtprotoSecret(inbound) oldInbound, err := s.GetInbound(inbound.Id) diff --git a/internal/web/service/shadowsocks_server_key_test.go b/internal/web/service/shadowsocks_server_key_test.go new file mode 100644 index 000000000..bc0806fe5 --- /dev/null +++ b/internal/web/service/shadowsocks_server_key_test.go @@ -0,0 +1,77 @@ +package service + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func ss2022Settings(method, serverKey string) string { + clientKey := base64.StdEncoding.EncodeToString(make([]byte, 16)) + if method != "2022-blake3-aes-128-gcm" { + clientKey = base64.StdEncoding.EncodeToString(make([]byte, 32)) + } + return fmt.Sprintf(`{"method":%q,"password":%q,"network":"tcp,udp","clients":[{"email":"ss-key","password":%q,"enable":true}]}`, + method, serverKey, clientKey) +} + +// xray-core refuses a wrong-size Shadowsocks-2022 server key while building the +// inbound, which keeps the whole core from starting, not just this inbound. +func TestShadowsocks2022ServerKeyRejected(t *testing.T) { + key32 := base64.StdEncoding.EncodeToString(make([]byte, 32)) + key16 := base64.StdEncoding.EncodeToString(make([]byte, 16)) + + t.Run("add", func(t *testing.T) { + setupConflictDB(t) + nodeID, fake := setupNodeRuntime(t) + ib := &model.Inbound{ + UserId: 1, NodeID: &nodeID, Tag: "ss-add", Enable: true, Port: 41801, Protocol: model.Shadowsocks, + Settings: ss2022Settings("2022-blake3-aes-128-gcm", key32), + } + _, _, err := (&InboundService{}).AddInbound(ib) + if err == nil || !strings.Contains(err.Error(), "2022-blake3-aes-128-gcm needs a 16-byte key") { + t.Fatalf("AddInbound with a 32-byte aes-128 key: err = %v", err) + } + if fake.addInbound.Load() != 0 { + t.Fatal("rejected inbound dispatched to the runtime") + } + }) + + t.Run("update", func(t *testing.T) { + setupConflictDB(t) + nodeID, fake := setupNodeRuntime(t) + svc := &InboundService{} + ib := &model.Inbound{ + UserId: 1, NodeID: &nodeID, Tag: "ss-update", Enable: true, Port: 41802, Protocol: model.Shadowsocks, + Settings: ss2022Settings("2022-blake3-aes-128-gcm", key16), + } + if _, _, err := svc.AddInbound(ib); err != nil { + t.Fatalf("AddInbound with a valid key: %v", err) + } + update := *ib + update.Settings = ss2022Settings("2022-blake3-aes-256-gcm", key16) + _, _, err := svc.UpdateInbound(&update) + if err == nil || !strings.Contains(err.Error(), "2022-blake3-aes-256-gcm needs a 32-byte key") { + t.Fatalf("UpdateInbound keeping a 16-byte key on aes-256: err = %v", err) + } + if fake.updateInbound.Load() != 0 { + t.Fatal("rejected update dispatched to the runtime") + } + }) + + // The core reads a non-base64 key as its raw bytes, so this one is valid. + t.Run("raw key the core accepts", func(t *testing.T) { + setupConflictDB(t) + nodeID, _ := setupNodeRuntime(t) + ib := &model.Inbound{ + UserId: 1, NodeID: &nodeID, Tag: "ss-raw", Enable: true, Port: 41803, Protocol: model.Shadowsocks, + Settings: ss2022Settings("2022-blake3-aes-128-gcm", "0123456789abcd-!"), + } + if _, _, err := (&InboundService{}).AddInbound(ib); err != nil { + t.Fatalf("AddInbound with a raw 16-byte key: %v", err) + } + }) +} diff --git a/internal/xray/shadowsocks2022.go b/internal/xray/shadowsocks2022.go new file mode 100644 index 000000000..181fb02ed --- /dev/null +++ b/internal/xray/shadowsocks2022.go @@ -0,0 +1,20 @@ +package xray + +import "github.com/xtls/xray-core/proxy/shadowsocks_2022" + +// Shadowsocks2022KeyBytes is the key size a Shadowsocks-2022 method takes, or 0 +// for any other method, whose password is free-form. +func Shadowsocks2022KeyBytes(method string) int { + cipher, err := shadowsocks_2022.GetCipherMethod(method) + if err != nil { + return 0 + } + return cipher.KeySaltLength +} + +// ValidShadowsocks2022Key reads key the way the core does: base64, else the raw +// string, and either way exactly the method's key size. +func ValidShadowsocks2022Key(method, key string) bool { + _, err := shadowsocks_2022.ParseKey(key, Shadowsocks2022KeyBytes(method)) + return err == nil +}