package service import ( "encoding/base64" "encoding/json" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/random" "github.com/mhsanaei/3x-ui/v3/internal/xray" "gorm.io/gorm" ) func hasForbiddenClientChar(s string) bool { for _, r := range s { if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f { return true } } return false } func validateClientEmail(email string) error { if hasForbiddenClientChar(email) { return common.NewError("client email contains an invalid character:", email) } return nil } func validateClientSubID(subID string) error { if hasForbiddenClientChar(subID) { return common.NewError("client subId contains an invalid character:", subID) } return nil } func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) { if payload == nil { return false, common.NewError("empty payload") } client := payload.Client if strings.TrimSpace(client.Email) == "" { return false, common.NewError("client email is required") } if err := validateClientEmail(client.Email); err != nil { return false, err } if err := validateClientSubID(client.SubID); err != nil { return false, err } if len(payload.InboundIds) == 0 { return false, common.NewError("at least one inbound is required") } if client.SubID == "" { client.SubID = uuid.NewString() } if !client.Enable { client.Enable = true } now := time.Now().UnixMilli() if client.CreatedAt == 0 { client.CreatedAt = now } client.UpdatedAt = now existing := &model.ClientRecord{} err := database.GetDB().Where("email = ?", client.Email).First(existing).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } emailTaken := !errors.Is(err, gorm.ErrRecordNotFound) if emailTaken { if existing.SubID == "" || existing.SubID != client.SubID { return false, common.NewError("email already in use:", client.Email) } } if client.SubID != "" { var subTaken int64 if err := database.GetDB().Model(&model.ClientRecord{}). Where("sub_id = ? AND email <> ?", client.SubID, client.Email). Count(&subTaken).Error; err != nil { return false, err } if subTaken > 0 { return false, common.NewError("subId already in use:", client.SubID) } } needRestart := false for _, ibId := range payload.InboundIds { inbound, getErr := inboundSvc.GetInbound(ibId) if getErr != nil { return needRestart, getErr } if err := s.fillProtocolDefaults(&client, inbound); err != nil { return needRestart, err } settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(client, inbound)}}) if mErr != nil { return needRestart, mErr } nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{ Id: ibId, Settings: string(settingsPayload), }) if addErr != nil { return needRestart, addErr } if nr { needRestart = true } } return needRestart, nil } func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error { switch ib.Protocol { case model.VMESS, model.VLESS: if c.ID == "" { c.ID = uuid.NewString() } case model.Trojan: if c.Password == "" { 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: if c.Auth == "" { c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "") } } return nil } func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client { if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) { c.Flow = "" } return c } 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 } func randomShadowsocksClientKey(method string) string { if n := shadowsocksKeyBytes(method); n > 0 { return random.Base64Bytes(n) } return strings.ReplaceAll(uuid.NewString(), "-", "") } 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 } func applyShadowsocksClientMethod(clients []any, settings map[string]any) { method, _ := settings["method"].(string) is2022 := strings.HasPrefix(method, "2022-blake3-") for i := range clients { cm, ok := clients[i].(map[string]any) if !ok { continue } if is2022 { if _, hasKey := cm["method"]; hasKey { delete(cm, "method") clients[i] = cm } continue } if method == "" { 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, inboundFilter ...int) (bool, error) { existing, err := s.GetByID(id) if err != nil { return false, err } inboundIds, err := s.GetInboundIdsForRecord(id) if err != nil { return false, err } if len(inboundFilter) > 0 { allow := make(map[int]struct{}, len(inboundFilter)) for _, fid := range inboundFilter { allow[fid] = struct{}{} } filtered := inboundIds[:0:0] for _, ibId := range inboundIds { if _, ok := allow[ibId]; ok { filtered = append(filtered, ibId) } } inboundIds = filtered } if strings.TrimSpace(updated.Email) == "" { return false, common.NewError("client email is required") } if err := validateClientEmail(updated.Email); err != nil { return false, err } if err := validateClientSubID(updated.SubID); err != nil { return false, err } if updated.SubID == "" { updated.SubID = existing.SubID } if updated.SubID == "" { updated.SubID = uuid.NewString() } updated.UpdatedAt = time.Now().UnixMilli() if updated.CreatedAt == 0 { updated.CreatedAt = existing.CreatedAt } // Preserve existing credentials when the caller omits them, so a partial // update (e.g. only changing traffic/expiry) doesn't silently rotate the // client's UUID/password/auth via fillProtocolDefaults. Supplying a new // value still rotates it intentionally. if updated.ID == "" { updated.ID = existing.UUID } if updated.Password == "" { updated.Password = existing.Password } if updated.Auth == "" { updated.Auth = existing.Auth } if updated.Email != existing.Email { var collisionCount int64 if err := database.GetDB().Model(&model.ClientRecord{}). Where("email = ? AND id <> ?", updated.Email, id). Count(&collisionCount).Error; err != nil { return false, err } if collisionCount > 0 { return false, common.NewError("Duplicate email:", updated.Email) } if err := database.GetDB().Model(&model.ClientRecord{}). Where("id = ?", id). Update("email", updated.Email).Error; err != nil { return false, err } } if updated.SubID != "" { var subCollision int64 if err := database.GetDB().Model(&model.ClientRecord{}). Where("sub_id = ? AND id <> ?", updated.SubID, id). Count(&subCollision).Error; err != nil { return false, err } if subCollision > 0 { return false, common.NewError("Duplicate subId:", updated.SubID) } } needRestart := false for _, ibId := range inboundIds { inbound, getErr := inboundSvc.GetInbound(ibId) if getErr != nil { if errors.Is(getErr, gorm.ErrRecordNotFound) { if err := database.GetDB(). Where("client_id = ? AND inbound_id = ?", id, ibId). Delete(&model.ClientInbound{}).Error; err != nil { return needRestart, err } continue } return needRestart, getErr } if existing.Email == "" { continue } if err := s.fillProtocolDefaults(&updated, inbound); err != nil { return needRestart, err } settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(updated, inbound)}}) if mErr != nil { return needRestart, mErr } nr, upErr := s.UpdateInboundClient(inboundSvc, &model.Inbound{ Id: ibId, Settings: string(settingsPayload), }, existing.Email) if upErr != nil { return needRestart, upErr } if nr { needRestart = true } } reverseStr := "" if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" { if b, mErr := json.Marshal(updated.Reverse); mErr == nil { reverseStr = string(b) } } if err := database.GetDB().Model(&model.ClientRecord{}). Where("id = ?", id). Update("reverse", reverseStr).Error; err != nil { return needRestart, err } if err := database.GetDB().Model(&model.ClientRecord{}). Where("id = ?", id). UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil { return needRestart, err } return needRestart, nil } func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) { existing, err := s.GetByID(id) if err != nil { return false, err } tombstoneClientEmail(existing.Email) inboundIds, err := s.GetInboundIdsForRecord(id) if err != nil { return false, err } needRestart := false for _, ibId := range inboundIds { if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil { if errors.Is(getErr, gorm.ErrRecordNotFound) { continue } return needRestart, getErr } // Always delete by email — the client's stable identity. This removes // every matching entry from the inbound's settings even when the stored // credential (UUID/password/auth) drifted from the inbound JSON, or a // duplicate entry with the same email exists. if existing.Email == "" { continue } nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, false) if delErr != nil { // The client is already absent from this inbound (data drift or a // retried delete). Skip it — deletion stays idempotent. if errors.Is(delErr, ErrClientNotInInbound) { continue } return needRestart, delErr } if nr { needRestart = true } } db := database.GetDB() if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil { return needRestart, err } if !keepTraffic && existing.Email != "" { if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil { return needRestart, err } if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { return needRestart, err } } if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil { return needRestart, err } return needRestart, nil } func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) { existing, err := s.GetByID(id) if err != nil { return false, err } currentIds, err := s.GetInboundIdsForRecord(id) if err != nil { return false, err } have := make(map[int]struct{}, len(currentIds)) for _, x := range currentIds { have[x] = struct{}{} } clientWire := existing.ToClient() flow, ffErr := s.EffectiveFlow(nil, id) if ffErr != nil { return false, ffErr } clientWire.Flow = flow clientWire.UpdatedAt = time.Now().UnixMilli() needRestart := false for _, ibId := range inboundIds { if _, attached := have[ibId]; attached { continue } inbound, getErr := inboundSvc.GetInbound(ibId) if getErr != nil { return needRestart, getErr } copyClient := *clientWire if err := s.fillProtocolDefaults(©Client, inbound); err != nil { return needRestart, err } settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}}) if mErr != nil { return needRestart, mErr } nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{ Id: ibId, Settings: string(settingsPayload), }) if addErr != nil { return needRestart, addErr } if nr { needRestart = true } } return needRestart, nil } func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) { return s.Create(inboundSvc, &ClientCreatePayload{ Client: client, InboundIds: []int{inboundId}, }) } func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) { if email == "" { return false, common.NewError("client email is required") } rec, err := s.GetRecordByEmail(nil, email) if err != nil { return false, err } return s.Detach(inboundSvc, rec.Id, []int{inboundId}) } func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) { if email == "" { return false, common.NewError("client email is required") } rec, err := s.GetRecordByEmail(nil, email) if err != nil { return false, err } return s.Attach(inboundSvc, rec.Id, inboundIds) } func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) { if email == "" { return false, common.NewError("client email is required") } rec, err := s.GetRecordByEmail(nil, email) if err != nil { return false, err } return s.Detach(inboundSvc, rec.Id, inboundIds) } func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) { if email == "" { return false, common.NewError("client email is required") } rec, err := s.GetRecordByEmail(nil, email) if err == nil { return s.Delete(inboundSvc, rec.Id, keepTraffic) } if !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } inboundIds, idsErr := s.findInboundIdsByClientEmail(email) if idsErr != nil { return false, idsErr } if len(inboundIds) == 0 { return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email)) } needRestart := false for _, ibId := range inboundIds { nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false) if delErr != nil { if errors.Is(delErr, ErrClientNotInInbound) { continue } return needRestart, delErr } if nr { needRestart = true } } if !keepTraffic { db := database.GetDB() if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil { return needRestart, err } if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil { return needRestart, err } } return needRestart, nil } func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) { if email == "" { return false, common.NewError("client email is required") } rec, err := s.GetRecordByEmail(nil, email) if err != nil { return false, err } return s.Update(inboundSvc, rec.Id, updated, inboundFilter...) } func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) { existing, err := s.GetByID(id) if err != nil { return false, err } currentIds, err := s.GetInboundIdsForRecord(id) if err != nil { return false, err } have := make(map[int]struct{}, len(currentIds)) for _, x := range currentIds { have[x] = struct{}{} } needRestart := false for _, ibId := range inboundIds { if _, attached := have[ibId]; !attached { continue } if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil { return needRestart, getErr } // Detach by email — the client's stable identity (see Delete). if existing.Email == "" { continue } nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, true) if delErr != nil { if errors.Is(delErr, ErrClientNotInInbound) { continue } return needRestart, delErr } if nr { needRestart = true } } return needRestart, nil }