|
|
@@ -678,6 +678,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
|
|
for _, ibId := range inboundIds {
|
|
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
|
|
if getErr != nil {
|
|
|
+ if errors.Is(getErr, gorm.ErrRecordNotFound) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
return needRestart, getErr
|
|
|
}
|
|
|
key := clientKeyForProtocol(inbound.Protocol, existing)
|
|
|
@@ -788,6 +791,99 @@ func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string,
|
|
|
return s.Attach(inboundSvc, rec.Id, inboundIds)
|
|
|
}
|
|
|
|
|
|
+// BulkAttachResult reports the outcome of a bulk attach across target inbounds.
|
|
|
+type BulkAttachResult struct {
|
|
|
+ Attached []string `json:"attached"`
|
|
|
+ Skipped []string `json:"skipped"`
|
|
|
+ Errors []string `json:"errors"`
|
|
|
+}
|
|
|
+
|
|
|
+// BulkAttach attaches the given existing clients (by email) to each target inbound,
|
|
|
+// reusing their identity (email/UUID/password/subId) and a shared traffic row. It adds
|
|
|
+// all clients to a target in a single AddInboundClient call, and reports clients already
|
|
|
+// present on a target as skipped.
|
|
|
+func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, inboundIds []int) (*BulkAttachResult, bool, error) {
|
|
|
+ result := &BulkAttachResult{}
|
|
|
+ if len(emails) == 0 || len(inboundIds) == 0 {
|
|
|
+ return result, false, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ records := make([]*model.ClientRecord, 0, len(emails))
|
|
|
+ seenEmail := make(map[string]struct{}, len(emails))
|
|
|
+ for _, email := range emails {
|
|
|
+ if email == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ key := strings.ToLower(email)
|
|
|
+ if _, ok := seenEmail[key]; ok {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ seenEmail[key] = struct{}{}
|
|
|
+ rec, err := s.GetRecordByEmail(nil, email)
|
|
|
+ if err != nil {
|
|
|
+ result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ records = append(records, rec)
|
|
|
+ }
|
|
|
+
|
|
|
+ needRestart := false
|
|
|
+ for _, ibId := range inboundIds {
|
|
|
+ inbound, err := inboundSvc.GetInbound(ibId)
|
|
|
+ if err != nil {
|
|
|
+ result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ existingClients, err := inboundSvc.GetClients(inbound)
|
|
|
+ if err != nil {
|
|
|
+ result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ have := make(map[string]struct{}, len(existingClients))
|
|
|
+ for _, c := range existingClients {
|
|
|
+ have[strings.ToLower(c.Email)] = struct{}{}
|
|
|
+ }
|
|
|
+
|
|
|
+ clientsToAdd := make([]model.Client, 0, len(records))
|
|
|
+ for _, rec := range records {
|
|
|
+ if _, attached := have[strings.ToLower(rec.Email)]; attached {
|
|
|
+ result.Skipped = append(result.Skipped, rec.Email)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ client := *rec.ToClient()
|
|
|
+ client.UpdatedAt = time.Now().UnixMilli()
|
|
|
+ if err := s.fillProtocolDefaults(&client, inbound); err != nil {
|
|
|
+ result.Errors = append(result.Errors, fmt.Sprintf("%s -> inbound %d: %v", rec.Email, ibId, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ clientsToAdd = append(clientsToAdd, client)
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(clientsToAdd) == 0 {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ payload, err := json.Marshal(map[string][]model.Client{"clients": clientsToAdd})
|
|
|
+ if err != nil {
|
|
|
+ result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ nr, err := s.AddInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)})
|
|
|
+ if err != nil {
|
|
|
+ result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if nr {
|
|
|
+ needRestart = true
|
|
|
+ }
|
|
|
+ for _, c := range clientsToAdd {
|
|
|
+ result.Attached = append(result.Attached, c.Email)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return result, needRestart, nil
|
|
|
+}
|
|
|
+
|
|
|
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
|
|
|
if email == "" {
|
|
|
return false, common.NewError("client email is required")
|
|
|
@@ -804,10 +900,74 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
|
|
|
return false, common.NewError("client email is required")
|
|
|
}
|
|
|
rec, err := s.GetRecordByEmail(nil, email)
|
|
|
- if err != nil {
|
|
|
+ if err == nil {
|
|
|
+ return s.Delete(inboundSvc, rec.Id, keepTraffic)
|
|
|
+ }
|
|
|
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
return false, err
|
|
|
}
|
|
|
- return s.Delete(inboundSvc, rec.Id, keepTraffic)
|
|
|
+ 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)
|
|
|
+ if delErr != nil {
|
|
|
+ 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
|
|
|
+}
|
|
|
+
|
|
|
+// findInboundIdsByClientEmail returns every inbound whose settings.clients[]
|
|
|
+// JSON contains an entry with the given email. Driver-portable (no JSON
|
|
|
+// operators) by parsing in Go — fine for the rare fallback path.
|
|
|
+func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) {
|
|
|
+ var inbounds []model.Inbound
|
|
|
+ if err := database.GetDB().
|
|
|
+ Select("id, settings").
|
|
|
+ Where("settings LIKE ?", "%"+email+"%").
|
|
|
+ Find(&inbounds).Error; err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ out := make([]int, 0, len(inbounds))
|
|
|
+ for _, ib := range inbounds {
|
|
|
+ var settings map[string]any
|
|
|
+ if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ clients, ok := settings["clients"].([]any)
|
|
|
+ if !ok {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ for _, c := range clients {
|
|
|
+ cm, ok := c.(map[string]any)
|
|
|
+ if !ok {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if cEmail, _ := cm["email"].(string); cEmail == email {
|
|
|
+ out = append(out, ib.Id)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out, nil
|
|
|
}
|
|
|
|
|
|
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
|