client_crud.go 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201
  1. package service
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "net/netip"
  8. "runtime/debug"
  9. "slices"
  10. "strings"
  11. "sync"
  12. "sync/atomic"
  13. "time"
  14. "unicode"
  15. "github.com/google/uuid"
  16. "github.com/mhsanaei/3x-ui/v3/internal/database"
  17. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  18. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  19. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  20. "github.com/mhsanaei/3x-ui/v3/internal/util/random"
  21. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  22. "gorm.io/gorm"
  23. )
  24. func hasForbiddenClientChar(s string) bool {
  25. for _, r := range s {
  26. if r == '/' || r == '\\' || r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
  27. return true
  28. }
  29. }
  30. return false
  31. }
  32. func validateClientEmail(email string) error {
  33. if hasForbiddenClientChar(email) {
  34. return common.NewError("client email contains an invalid character:", email)
  35. }
  36. return nil
  37. }
  38. func validateClientSubID(subID string) error {
  39. if hasForbiddenClientChar(subID) {
  40. return common.NewError("client subId contains an invalid character:", subID)
  41. }
  42. return nil
  43. }
  44. // Rejected rather than coerced: an unknown cycle would leave the operator with
  45. // a field that reads as configured while no job ever selects the client.
  46. func validateClientTrafficReset(period string, day int) error {
  47. switch period {
  48. case "", "never", "hourly", "daily", "weekly", "monthly":
  49. default:
  50. return common.NewError("client trafficReset must be never, hourly, daily, weekly or monthly, got:", period)
  51. }
  52. if day < 0 || day > 31 {
  53. return common.NewError("client trafficResetDay must be between 0 and 31, got:", day)
  54. }
  55. return nil
  56. }
  57. // Rejected rather than clamped: nextCalendarRenewal would silently move an
  58. // out-of-range day, and a negative one drops out of the renewal query entirely.
  59. func validateClientResetDay(day int) error {
  60. if day < 0 || day > 31 {
  61. return common.NewError("client resetDay must be between 0 and 31, got:", day)
  62. }
  63. return nil
  64. }
  65. // Rejected rather than coerced: a negative cap reads as "unlimited" to a caller
  66. // but selects nothing, so the client would silently stop renewing.
  67. func validateClientResetMax(resetMax int) error {
  68. if resetMax < 0 {
  69. return common.NewError("client resetMax must not be negative, got:", resetMax)
  70. }
  71. return nil
  72. }
  73. func validateClientRenewal(client model.Client) error {
  74. if err := validateClientResetDay(client.ResetDay); err != nil {
  75. return err
  76. }
  77. if client.ResetWeekday < 0 || client.ResetWeekday > 7 {
  78. return common.NewError("client resetWeekday must be between 0 and 7, got:", client.ResetWeekday)
  79. }
  80. if client.ResetWeekday > 0 && (client.Reset > 0 || client.ResetDay > 0) {
  81. return common.NewError("client weekly renewal cannot be combined with reset or resetDay")
  82. }
  83. return nil
  84. }
  85. func validateClientsRenewal(clients []model.Client) error {
  86. for _, client := range clients {
  87. if err := validateClientRenewal(client); err != nil {
  88. return err
  89. }
  90. }
  91. return nil
  92. }
  93. // normalizeClientTrafficReset stores what the inbound path would store, so the
  94. // day never reaches the DB as a 0 that three layers downstream each clamp to 1.
  95. func normalizeClientTrafficReset(c *model.Client) {
  96. if c.TrafficReset == "" {
  97. c.TrafficReset = "never"
  98. }
  99. c.TrafficResetDay = normalizeTrafficResetDay(c.TrafficResetDay)
  100. }
  101. // ClientResetCycle is the slice of a client the reset job needs: enough to know
  102. // whether it is due, and whether its disable is the quota's doing or the operator's.
  103. type ClientResetCycle struct {
  104. Email string
  105. TrafficResetDay int
  106. Enable bool
  107. Total int64
  108. Used int64
  109. }
  110. // Depleted reports a client the quota switched off. A reset restores that one;
  111. // a client disabled below its quota was switched off by hand and stays off.
  112. func (c ClientResetCycle) Depleted() bool {
  113. return c.Total > 0 && c.Used >= c.Total
  114. }
  115. // GetClientsByTrafficReset returns the clients whose own reset cycle matches the
  116. // period, independent of the cycle configured on the inbounds they belong to.
  117. func (s *ClientService) GetClientsByTrafficReset(period string) ([]ClientResetCycle, error) {
  118. var cycles []ClientResetCycle
  119. err := database.GetDB().Table("clients c").
  120. Select("c.email, c.traffic_reset_day, c.enable, COALESCE(ct.total, 0) AS total, COALESCE(ct.up, 0) + COALESCE(ct.down, 0) AS used").
  121. Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
  122. Where("c.traffic_reset = ?", period).
  123. Scan(&cycles).Error
  124. if err != nil {
  125. return nil, err
  126. }
  127. return cycles, nil
  128. }
  129. // Create applies the client to every requested inbound: one failing inbound no
  130. // longer aborts the others, so the error can name several and needRestart holds.
  131. func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
  132. if payload == nil {
  133. return false, common.NewError("empty payload")
  134. }
  135. client := payload.Client
  136. if strings.TrimSpace(client.Email) == "" {
  137. return false, common.NewError("client email is required")
  138. }
  139. if err := validateClientEmail(client.Email); err != nil {
  140. return false, err
  141. }
  142. if err := validateClientSubID(client.SubID); err != nil {
  143. return false, err
  144. }
  145. if err := validateClientRenewal(client); err != nil {
  146. return false, err
  147. }
  148. if err := validateClientResetMax(client.ResetMax); err != nil {
  149. return false, err
  150. }
  151. if err := validateClientTrafficReset(client.TrafficReset, client.TrafficResetDay); err != nil {
  152. return false, err
  153. }
  154. normalizeClientTrafficReset(&client)
  155. if len(payload.InboundIds) == 0 {
  156. return false, common.NewError("at least one inbound is required")
  157. }
  158. if client.SubID == "" {
  159. client.SubID = uuid.NewString()
  160. }
  161. // Enable: omit defaults true via ClientCreatePayload.UnmarshalJSON; explicit false kept.
  162. now := time.Now().UnixMilli()
  163. if client.CreatedAt == 0 {
  164. client.CreatedAt = now
  165. }
  166. client.UpdatedAt = now
  167. existing := &model.ClientRecord{}
  168. err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
  169. if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
  170. return false, err
  171. }
  172. emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
  173. if emailTaken {
  174. if existing.SubID == "" || existing.SubID != client.SubID {
  175. return false, common.NewError("email already in use:", client.Email)
  176. }
  177. // Reuse stored credentials when re-adding an existing identity, or
  178. // fillProtocolDefaults mints a fresh UUID that desyncs other inbounds.
  179. if client.ID == "" {
  180. client.ID = existing.UUID
  181. }
  182. if client.Password == "" {
  183. client.Password = existing.Password
  184. }
  185. if client.Auth == "" {
  186. client.Auth = existing.Auth
  187. }
  188. if client.Secret == "" {
  189. client.Secret = existing.Secret
  190. }
  191. }
  192. if client.SubID != "" {
  193. var subTaken int64
  194. if err := database.GetDB().Model(&model.ClientRecord{}).
  195. Where("sub_id = ? AND email <> ?", client.SubID, client.Email).
  196. Count(&subTaken).Error; err != nil {
  197. return false, err
  198. }
  199. if subTaken > 0 {
  200. return false, common.NewError("subId already in use:", client.SubID)
  201. }
  202. }
  203. // Prepared before any inbound is written: fillProtocolDefaults mints the
  204. // shared credentials on the first inbound and every later one reuses them.
  205. adds := make([]*model.Inbound, 0, len(payload.InboundIds))
  206. for _, ibId := range payload.InboundIds {
  207. inbound, getErr := inboundSvc.GetInbound(ibId)
  208. if getErr != nil {
  209. return false, fmt.Errorf("inbound %d: %w", ibId, getErr)
  210. }
  211. if err := s.fillProtocolDefaults(&client, inbound); err != nil {
  212. return false, fmt.Errorf("inbound %d: %w", ibId, err)
  213. }
  214. clientForInbound := client
  215. if ips, ok := client.AllowedIPsByInbound[ibId]; ok {
  216. clientForInbound.AllowedIPs = ips
  217. } else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
  218. // The shared AllowedIPs value (e.g. from a single-field legacy
  219. // caller) came from a different subnet than this inbound's own --
  220. // clear it so defaultAmneziaWGClients allocates a fresh, correct
  221. // address for THIS inbound instead of persisting an unroutable
  222. // peer. Same reasoning as addressesFitAmneziaWGInbound's own doc
  223. // comment on the Attach path.
  224. clientForInbound.AllowedIPs = nil
  225. }
  226. settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
  227. if mErr != nil {
  228. return false, fmt.Errorf("inbound %d: %w", ibId, mErr)
  229. }
  230. adds = append(adds, &model.Inbound{Id: ibId, Settings: string(settingsPayload)})
  231. }
  232. needRestart, fanoutErr := s.fanoutInboundClientAdds(inboundSvc, adds)
  233. if fanoutErr != nil {
  234. // Never on a failed create: this retrims the devices of an email that
  235. // already existed, and a create the panel reported as failed must not.
  236. return needRestart, fanoutErr
  237. }
  238. // A re-created email is a live identity again: a delete tombstone left
  239. // standing makes the next node merge prune the new client's inbound links.
  240. withdrawClientTombstones(client.Email)
  241. return needRestart, s.setClientLimitHwidByEmail(client.Email, payload.LimitHwid)
  242. }
  243. // inboundFanoutConcurrency caps how many inbounds one client op applies at
  244. // once, so a client spanning many of them can't start an unbounded RPC burst.
  245. const inboundFanoutConcurrency = 4
  246. // inboundApply is one inbound's share of a client op, ready to run.
  247. type inboundApply struct {
  248. id int
  249. run func() (bool, error)
  250. }
  251. // fanoutInboundApplies runs the applies with the node pushes overlapping, so a
  252. // client spanning several nodes no longer costs one RPC round-trip per node.
  253. func fanoutInboundApplies(applies []inboundApply) (bool, error) {
  254. var needRestart atomic.Bool
  255. errs := make([]error, len(applies))
  256. sem := make(chan struct{}, inboundFanoutConcurrency)
  257. var wg sync.WaitGroup
  258. for i := range applies {
  259. wg.Add(1)
  260. sem <- struct{}{}
  261. go func() {
  262. defer wg.Done()
  263. defer func() { <-sem }()
  264. // Off the request goroutine gin's Recovery no longer covers this,
  265. // so an unrecovered panic here would take the whole panel down.
  266. defer func() {
  267. if r := recover(); r != nil {
  268. // The apply may already have committed, so ask for the
  269. // restart the lost return value can no longer report.
  270. needRestart.Store(true)
  271. errs[i] = fmt.Errorf("inbound %d: panic: %v", applies[i].id, r)
  272. logger.Errorf("panic applying client change to inbound %d: %v\n%s", applies[i].id, r, debug.Stack())
  273. }
  274. }()
  275. nr, err := applies[i].run()
  276. if nr {
  277. needRestart.Store(true)
  278. }
  279. if err != nil {
  280. errs[i] = fmt.Errorf("inbound %d: %w", applies[i].id, err)
  281. }
  282. }()
  283. }
  284. wg.Wait()
  285. return needRestart.Load(), errors.Join(errs...)
  286. }
  287. // fanoutInboundClientAdds applies one payload per inbound.
  288. func (s *ClientService) fanoutInboundClientAdds(inboundSvc *InboundService, adds []*model.Inbound) (bool, error) {
  289. applies := make([]inboundApply, 0, len(adds))
  290. for _, add := range adds {
  291. applies = append(applies, inboundApply{id: add.Id, run: func() (bool, error) {
  292. return s.AddInboundClient(inboundSvc, add)
  293. }})
  294. }
  295. return fanoutInboundApplies(applies)
  296. }
  297. // fanoutInboundResults runs one job per inbound with the node pushes
  298. // overlapping, so a bulk op costs one RPC round-trip instead of one per node.
  299. // limit is the caller's own cap: an op that allocates tunnel addresses passes 1,
  300. // because allocation reads a cross-inbound used-set before it writes.
  301. func fanoutInboundResults[T any](inboundIds []int, limit int, run func(i int) T) ([]T, []error) {
  302. if limit < 1 {
  303. limit = 1
  304. }
  305. out := make([]T, len(inboundIds))
  306. errs := make([]error, len(inboundIds))
  307. sem := make(chan struct{}, limit)
  308. var wg sync.WaitGroup
  309. for i := range inboundIds {
  310. wg.Add(1)
  311. sem <- struct{}{}
  312. go func() {
  313. defer wg.Done()
  314. defer func() { <-sem }()
  315. // Off the request goroutine gin's Recovery no longer covers this,
  316. // so an unrecovered panic here would take the whole panel down.
  317. defer func() {
  318. if r := recover(); r != nil {
  319. errs[i] = fmt.Errorf("inbound %d: panic: %v", inboundIds[i], r)
  320. logger.Errorf("panic applying bulk client change to inbound %d: %v\n%s", inboundIds[i], r, debug.Stack())
  321. }
  322. }()
  323. out[i] = run(i)
  324. }()
  325. }
  326. wg.Wait()
  327. return out, errs
  328. }
  329. // addFanoutLimit serializes an add that touches a tunnel inbound. WireGuard and
  330. // AmneziaWG pick a free peer address by reading every inbound's used-set first,
  331. // so two overlapping allocations hand out the same one and the second is refused.
  332. func addFanoutLimit(anyTunnel bool) int {
  333. if anyTunnel {
  334. return 1
  335. }
  336. return inboundFanoutConcurrency
  337. }
  338. // sortedInboundIds gives the fanout a stable order, so which inbound wins a
  339. // per-email report no longer depends on Go's map iteration order.
  340. func sortedInboundIds[V any](byInbound map[int]V) []int {
  341. ids := make([]int, 0, len(byInbound))
  342. for id := range byInbound {
  343. ids = append(ids, id)
  344. }
  345. slices.Sort(ids)
  346. return ids
  347. }
  348. // markInboundNodesDirty makes a half-applied client edit unobservable to a node
  349. // snapshot merge, which skips a node whose config is already flagged dirty.
  350. func markInboundNodesDirty(inboundIds []int) error {
  351. if len(inboundIds) == 0 {
  352. return nil
  353. }
  354. var nodeIDs []int
  355. for _, batch := range chunkInts(inboundIds, sqlInChunk) {
  356. var ids []int
  357. if err := database.GetDB().Model(&model.Inbound{}).
  358. Where("id IN ? AND node_id IS NOT NULL", batch).
  359. Distinct().Pluck("node_id", &ids).Error; err != nil {
  360. return err
  361. }
  362. nodeIDs = append(nodeIDs, ids...)
  363. }
  364. if len(nodeIDs) == 0 {
  365. return nil
  366. }
  367. return runSerializedTx(func(tx *gorm.DB) error {
  368. svc := &NodeService{}
  369. for _, id := range nodeIDs {
  370. if err := svc.MarkNodeDirtyTx(tx, id); err != nil {
  371. return err
  372. }
  373. }
  374. return nil
  375. })
  376. }
  377. func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
  378. switch ib.Protocol {
  379. case model.VMESS, model.VLESS:
  380. if c.ID == "" {
  381. c.ID = uuid.NewString()
  382. }
  383. case model.Trojan:
  384. if c.Password == "" {
  385. c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
  386. }
  387. case model.Shadowsocks:
  388. method := shadowsocksMethodFromSettings(ib.Settings)
  389. if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
  390. c.Password = randomShadowsocksClientKey(method)
  391. }
  392. case model.Hysteria:
  393. if c.Auth == "" {
  394. c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
  395. }
  396. case model.MTProto:
  397. if c.Secret == "" {
  398. c.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(ib.Settings))
  399. }
  400. case model.TUIC:
  401. if c.ID == "" {
  402. c.ID = uuid.NewString()
  403. }
  404. if c.Password == "" {
  405. c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
  406. }
  407. }
  408. return nil
  409. }
  410. // defaultMtprotoDomain is the FakeTLS fronting domain used when an mtproto
  411. // inbound carries no fakeTlsDomain of its own; it mirrors the frontend default.
  412. const defaultMtprotoDomain = "www.cloudflare.com"
  413. // mtprotoDomainFromSettings returns the inbound-level FakeTLS domain, falling
  414. // back to the default when unset, so a generated client secret always fronts a
  415. // real hostname.
  416. func mtprotoDomainFromSettings(settings string) string {
  417. domain := ""
  418. if settings != "" {
  419. var m map[string]any
  420. if err := json.Unmarshal([]byte(settings), &m); err == nil {
  421. domain, _ = m["fakeTlsDomain"].(string)
  422. }
  423. }
  424. domain = strings.TrimSpace(domain)
  425. if domain == "" {
  426. return defaultMtprotoDomain
  427. }
  428. return domain
  429. }
  430. func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
  431. if ib.DisableFlow || !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) {
  432. c.Flow = ""
  433. }
  434. return c
  435. }
  436. func shadowsocksMethodFromSettings(settings string) string {
  437. if settings == "" {
  438. return ""
  439. }
  440. var m map[string]any
  441. if err := json.Unmarshal([]byte(settings), &m); err != nil {
  442. return ""
  443. }
  444. method, _ := m["method"].(string)
  445. return method
  446. }
  447. func randomShadowsocksClientKey(method string) string {
  448. if n := shadowsocksKeyBytes(method); n > 0 {
  449. return random.Base64Bytes(n)
  450. }
  451. return strings.ReplaceAll(uuid.NewString(), "-", "")
  452. }
  453. func validShadowsocksClientKey(method, key string) bool {
  454. n := shadowsocksKeyBytes(method)
  455. if n == 0 {
  456. return key != ""
  457. }
  458. decoded, err := base64.StdEncoding.DecodeString(key)
  459. if err != nil {
  460. return false
  461. }
  462. return len(decoded) == n
  463. }
  464. func shadowsocksKeyBytes(method string) int {
  465. switch method {
  466. case "2022-blake3-aes-128-gcm":
  467. return 16
  468. case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
  469. return 32
  470. }
  471. return 0
  472. }
  473. // normalizeShadowsocksClientKeys rewrites any Shadowsocks-2022 client password
  474. // whose decoded length no longer matches settings.method, which happens after the
  475. // inbound method is switched between ciphers of different key sizes (e.g.
  476. // aes-256↔aes-128). A wrong-length uPSK makes xray reject the user, so the link
  477. // fails to connect; regenerating restores a valid key (clients must re-fetch).
  478. // Non-Shadowsocks / legacy-SS settings pass through unchanged.
  479. func normalizeShadowsocksClientKeys(settings string) (string, bool) {
  480. method := shadowsocksMethodFromSettings(settings)
  481. if shadowsocksKeyBytes(method) == 0 {
  482. return settings, false
  483. }
  484. var m map[string]any
  485. if err := json.Unmarshal([]byte(settings), &m); err != nil {
  486. return settings, false
  487. }
  488. clients, ok := m["clients"].([]any)
  489. if !ok {
  490. return settings, false
  491. }
  492. changed := false
  493. for i := range clients {
  494. c, ok := clients[i].(map[string]any)
  495. if !ok {
  496. continue
  497. }
  498. if pw, _ := c["password"].(string); validShadowsocksClientKey(method, pw) {
  499. continue
  500. }
  501. c["password"] = randomShadowsocksClientKey(method)
  502. clients[i] = c
  503. changed = true
  504. }
  505. if !changed {
  506. return settings, false
  507. }
  508. m["clients"] = clients
  509. bs, err := json.MarshalIndent(m, "", " ")
  510. if err != nil {
  511. return settings, false
  512. }
  513. return string(bs), true
  514. }
  515. func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
  516. method, _ := settings["method"].(string)
  517. is2022 := strings.HasPrefix(method, "2022-blake3-")
  518. for i := range clients {
  519. cm, ok := clients[i].(map[string]any)
  520. if !ok {
  521. continue
  522. }
  523. if is2022 {
  524. if _, hasKey := cm["method"]; hasKey {
  525. delete(cm, "method")
  526. clients[i] = cm
  527. }
  528. continue
  529. }
  530. if method == "" {
  531. continue
  532. }
  533. if existing, _ := cm["method"].(string); existing != "" {
  534. continue
  535. }
  536. cm["method"] = method
  537. clients[i] = cm
  538. }
  539. }
  540. func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
  541. existing, err := s.GetByID(id)
  542. if err != nil {
  543. return false, err
  544. }
  545. inboundIds, err := s.GetInboundIdsForRecord(id)
  546. if err != nil {
  547. return false, err
  548. }
  549. // The rename rewrites the one shared client record, so every node holding
  550. // this client goes stale — not just the ones an inboundIds filter applies.
  551. attachedIds := append([]int(nil), inboundIds...)
  552. if len(inboundFilter) > 0 {
  553. allow := make(map[int]struct{}, len(inboundFilter))
  554. for _, fid := range inboundFilter {
  555. allow[fid] = struct{}{}
  556. }
  557. filtered := inboundIds[:0:0]
  558. for _, ibId := range inboundIds {
  559. if _, ok := allow[ibId]; ok {
  560. filtered = append(filtered, ibId)
  561. }
  562. }
  563. inboundIds = filtered
  564. }
  565. if strings.TrimSpace(updated.Email) == "" {
  566. return false, common.NewError("client email is required")
  567. }
  568. if err := validateClientEmail(updated.Email); err != nil {
  569. return false, err
  570. }
  571. if err := validateClientSubID(updated.SubID); err != nil {
  572. return false, err
  573. }
  574. if err := validateClientRenewal(updated); err != nil {
  575. return false, err
  576. }
  577. if err := validateClientResetMax(updated.ResetMax); err != nil {
  578. return false, err
  579. }
  580. if err := validateClientTrafficReset(updated.TrafficReset, updated.TrafficResetDay); err != nil {
  581. return false, err
  582. }
  583. normalizeClientTrafficReset(&updated)
  584. if updated.SubID == "" {
  585. updated.SubID = existing.SubID
  586. }
  587. if updated.SubID == "" {
  588. updated.SubID = uuid.NewString()
  589. }
  590. updated.UpdatedAt = time.Now().UnixMilli()
  591. if updated.CreatedAt == 0 {
  592. updated.CreatedAt = existing.CreatedAt
  593. }
  594. // Preserve existing credentials when the caller omits them, so a partial
  595. // update (e.g. only changing traffic/expiry) doesn't silently rotate the
  596. // client's UUID/password/auth via fillProtocolDefaults. Supplying a new
  597. // value still rotates it intentionally.
  598. if updated.ID == "" {
  599. updated.ID = existing.UUID
  600. }
  601. if updated.Password == "" {
  602. updated.Password = existing.Password
  603. }
  604. if updated.Auth == "" {
  605. updated.Auth = existing.Auth
  606. }
  607. if updated.Secret == "" {
  608. updated.Secret = existing.Secret
  609. }
  610. if updated.Email != existing.Email {
  611. var collisionCount int64
  612. if err := database.GetDB().Model(&model.ClientRecord{}).
  613. Where("email = ? AND id <> ?", updated.Email, id).
  614. Count(&collisionCount).Error; err != nil {
  615. return false, err
  616. }
  617. if collisionCount > 0 {
  618. return false, common.NewError("Duplicate email:", updated.Email)
  619. }
  620. }
  621. if updated.SubID != existing.SubID {
  622. var subCollision int64
  623. if err := database.GetDB().Model(&model.ClientRecord{}).
  624. Where("sub_id = ? AND id <> ?", updated.SubID, id).
  625. Count(&subCollision).Error; err != nil {
  626. return false, err
  627. }
  628. if subCollision > 0 {
  629. return false, common.NewError("Duplicate subId:", updated.SubID)
  630. }
  631. }
  632. tunnelCount, tcErr := tunnelInboundCount(inboundIds)
  633. if tcErr != nil {
  634. return false, tcErr
  635. }
  636. // Built before any inbound is written, as in Create: fillProtocolDefaults
  637. // mints the shared credentials on the first inbound, later ones reuse them.
  638. applies := make([]inboundApply, 0, len(inboundIds))
  639. for _, ibId := range inboundIds {
  640. inbound, getErr := inboundSvc.GetInbound(ibId)
  641. if getErr != nil {
  642. if errors.Is(getErr, gorm.ErrRecordNotFound) {
  643. if err := database.GetDB().
  644. Where("client_id = ? AND inbound_id = ?", id, ibId).
  645. Delete(&model.ClientInbound{}).Error; err != nil {
  646. return false, err
  647. }
  648. continue
  649. }
  650. return false, getErr
  651. }
  652. if existing.Email == "" {
  653. continue
  654. }
  655. if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
  656. return false, err
  657. }
  658. clientForInbound := updated
  659. if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
  660. clientForInbound.AllowedIPs = ips
  661. } else if tunnelCount > 1 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
  662. // One shared peer field set cannot describe several peers: broadcast
  663. // it and they all end up with the same keys and tunnel address.
  664. clientForInbound.AllowedIPs = nil
  665. clientForInbound.PrivateKey = ""
  666. clientForInbound.PublicKey = ""
  667. clientForInbound.PreSharedKey = ""
  668. } else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
  669. // A single shared AllowedIPs field (the common case for a caller
  670. // that never sends AllowedIPsByInbound) must never overwrite an
  671. // inbound it doesn't belong to -- e.g. a client attached to both
  672. // wg and awg saving its wg-labeled address would otherwise get
  673. // that same address silently written into the awg peer config
  674. // too. Clearing it here makes UpdateInboundClient's own
  675. // empty-AllowedIPs carry-forward (see its WireGuard/AmneziaWG
  676. // branch) preserve THIS inbound's existing, correct value
  677. // instead.
  678. clientForInbound.AllowedIPs = nil
  679. }
  680. settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
  681. if mErr != nil {
  682. return false, mErr
  683. }
  684. data := &model.Inbound{Id: ibId, Settings: string(settingsPayload)}
  685. applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
  686. return s.UpdateInboundClient(inboundSvc, data, existing.Email)
  687. }})
  688. }
  689. // Each apply marks only its OWN node dirty, so between the first and last
  690. // one a merge could resurrect the pre-edit email as a second client.
  691. if err := markInboundNodesDirty(attachedIds); err != nil {
  692. return false, err
  693. }
  694. needRestart, applyErr := fanoutInboundApplies(applies)
  695. if applyErr != nil {
  696. return needRestart, applyErr
  697. }
  698. // UpdateInboundClient renames the record atomically with each inbound's
  699. // settings JSON; this direct write only covers records with no inbound left.
  700. if updated.Email != existing.Email {
  701. if err := database.GetDB().Model(&model.ClientRecord{}).
  702. Where("id = ? AND email = ?", id, existing.Email).
  703. Update("email", updated.Email).Error; err != nil {
  704. return needRestart, err
  705. }
  706. }
  707. if len(inboundIds) == 0 {
  708. merged := *existing
  709. applyClientRecordMerge(&merged, updated.ToRecord())
  710. if err := database.GetDB().Model(&model.ClientRecord{}).
  711. Where("id = ?", id).
  712. Updates(map[string]any{
  713. "sub_id": merged.SubID,
  714. "uuid": merged.UUID,
  715. "password": merged.Password,
  716. "auth": merged.Auth,
  717. "secret": merged.Secret,
  718. "flow": merged.Flow,
  719. "security": merged.Security,
  720. "wg_private_key": merged.PrivateKey,
  721. "wg_public_key": merged.PublicKey,
  722. "wg_allowed_ips": merged.AllowedIPs,
  723. "wg_pre_shared_key": merged.PreSharedKey,
  724. "wg_keep_alive": merged.KeepAlive,
  725. "limit_ip": merged.LimitIP,
  726. "total_gb": merged.TotalGB,
  727. "expiry_time": merged.ExpiryTime,
  728. "tg_id": merged.TgID,
  729. "comment": merged.Comment,
  730. "reset": merged.Reset,
  731. "reset_day": merged.ResetDay,
  732. "reset_weekday": merged.ResetWeekday,
  733. "reset_max": merged.ResetMax,
  734. "traffic_reset": merged.TrafficReset,
  735. "traffic_reset_day": merged.TrafficResetDay,
  736. }).Error; err != nil {
  737. return needRestart, err
  738. }
  739. }
  740. reverseStr := ""
  741. if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
  742. if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
  743. reverseStr = string(b)
  744. }
  745. }
  746. if err := database.GetDB().Model(&model.ClientRecord{}).
  747. Where("id = ?", id).
  748. Update("reverse", reverseStr).Error; err != nil {
  749. return needRestart, err
  750. }
  751. // Persist the group explicitly. SyncInbound deliberately preserves the
  752. // stored group when the inbound settings carry none — so a node snapshot or a
  753. // group-less settings rebuild can't wipe it (see SyncInbound + its tests).
  754. // That guard also meant clearing the group in the client editor never took
  755. // effect. The editor always round-trips the field, so apply it here,
  756. // including the empty string that removes the client from its group.
  757. if err := database.GetDB().Model(&model.ClientRecord{}).
  758. Where("id = ?", id).
  759. UpdateColumn("group_name", updated.Group).Error; err != nil {
  760. return needRestart, err
  761. }
  762. // Same shape as the group write above: SyncInbound keeps a stored ad-tag
  763. // when the incoming settings carry none, so clearing the override must be
  764. // applied here, where the editor always round-trips the field.
  765. if err := database.GetDB().Model(&model.ClientRecord{}).
  766. Where("id = ?", id).
  767. UpdateColumn("ad_tag", updated.AdTag).Error; err != nil {
  768. return needRestart, err
  769. }
  770. if err := database.GetDB().Model(&model.ClientRecord{}).
  771. Where("id = ?", id).
  772. UpdateColumn("enable", updated.Enable).Error; err != nil {
  773. return needRestart, err
  774. }
  775. if err := s.setClientLimitHwidByEmail(updated.Email, limitHwid); err != nil {
  776. return needRestart, err
  777. }
  778. if err := database.GetDB().Model(&model.ClientRecord{}).
  779. Where("id = ?", id).
  780. UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
  781. return needRestart, err
  782. }
  783. return needRestart, nil
  784. }
  785. func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
  786. existing, err := s.GetByID(id)
  787. if err != nil {
  788. return false, err
  789. }
  790. tombstoneClientEmail(existing.Email)
  791. inboundIds, err := s.GetInboundIdsForRecord(id)
  792. if err != nil {
  793. withdrawClientTombstones(existing.Email)
  794. return false, err
  795. }
  796. applies := make([]inboundApply, 0, len(inboundIds))
  797. var delErrs []error
  798. for _, ibId := range inboundIds {
  799. if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
  800. if errors.Is(getErr, gorm.ErrRecordNotFound) {
  801. continue
  802. }
  803. delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, getErr))
  804. continue
  805. }
  806. // Always delete by email — the client's stable identity. This removes
  807. // every matching entry from the inbound's settings even when the stored
  808. // credential (UUID/password/auth) drifted from the inbound JSON, or a
  809. // duplicate entry with the same email exists.
  810. if existing.Email == "" {
  811. continue
  812. }
  813. applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
  814. nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, keepTraffic, true)
  815. // The client is already absent from this inbound (data drift or a
  816. // retried delete). Skip it — deletion stays idempotent.
  817. if errors.Is(delErr, ErrClientNotInInbound) {
  818. return nr, nil
  819. }
  820. return nr, delErr
  821. }})
  822. }
  823. needRestart, applyErr := fanoutInboundApplies(applies)
  824. if applyErr != nil {
  825. delErrs = append(delErrs, applyErr)
  826. }
  827. // A failed inbound still holds the client in its settings JSON: keep the
  828. // record so the next delete retries exactly the leftovers, and report it.
  829. // The tombstone lifts with it, or the next node merge finishes the deletion.
  830. if len(delErrs) > 0 {
  831. withdrawClientTombstones(existing.Email)
  832. return needRestart, errors.Join(delErrs...)
  833. }
  834. if err := runSerializedTx(func(tx *gorm.DB) error {
  835. if existing.Email != "" {
  836. if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil {
  837. return err
  838. }
  839. }
  840. if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
  841. return err
  842. }
  843. if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
  844. return err
  845. }
  846. if err := clearClientHwidsBySubIDTx(tx, existing.SubID); err != nil {
  847. return err
  848. }
  849. if !keepTraffic && existing.Email != "" {
  850. if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
  851. return err
  852. }
  853. if err := clearGlobalTraffic(tx, existing.Email); err != nil {
  854. return err
  855. }
  856. if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
  857. return err
  858. }
  859. if err := tx.Where("email = ?", existing.Email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
  860. return err
  861. }
  862. }
  863. return tx.Delete(&model.ClientRecord{}, id).Error
  864. }); err != nil {
  865. withdrawClientTombstones(existing.Email)
  866. return needRestart, err
  867. }
  868. return needRestart, nil
  869. }
  870. // hasTunnelAttachment reports whether any of inboundIds is a currently
  871. // existing WireGuard or AmneziaWG inbound. Inbounds that fail to load are
  872. // skipped rather than treated as an error -- Attach's own loop already
  873. // surfaces a real error for any inbound it can't load when it gets there.
  874. func (s *ClientService) hasTunnelAttachment(inboundSvc *InboundService, inboundIds []int) bool {
  875. for _, ibId := range inboundIds {
  876. inbound, err := inboundSvc.GetInbound(ibId)
  877. if err != nil {
  878. continue
  879. }
  880. if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG {
  881. return true
  882. }
  883. }
  884. return false
  885. }
  886. // tunnelInboundCount reports how many of inboundIds are WireGuard/AmneziaWG,
  887. // i.e. how many independent peers one shared field set would be written to.
  888. func tunnelInboundCount(inboundIds []int) (int64, error) {
  889. if len(inboundIds) == 0 {
  890. return 0, nil
  891. }
  892. var n int64
  893. err := database.GetDB().Model(&model.Inbound{}).
  894. Where("id IN ? AND protocol IN ?", inboundIds, []model.Protocol{model.WireGuard, model.AmneziaWG}).
  895. Count(&n).Error
  896. return n, err
  897. }
  898. // addressesFitAmneziaWGInbound reports whether every entry in addrs falls
  899. // inside ib's own configured subnet(s). AmneziaWG only: its kernel interface
  900. // Address is exactly that subnet, so an address inherited from elsewhere (an
  901. // identity attached to a WireGuard inbound first, say) produces a peer that
  902. // can never connect -- Attach allocates fresh instead.
  903. func addressesFitAmneziaWGInbound(addrs []string, ib *model.Inbound) bool {
  904. if ib.Protocol != model.AmneziaWG || len(addrs) == 0 {
  905. return true
  906. }
  907. v4Base, v6Base, err := defaultAmneziaWGSubnetBases(ib.Settings)
  908. if err != nil {
  909. return false
  910. }
  911. bases := make([]netip.Prefix, 0, 2)
  912. for _, base := range []string{v4Base, v6Base} {
  913. if base == "" {
  914. continue
  915. }
  916. prefix, pErr := netip.ParsePrefix(base)
  917. if pErr != nil {
  918. return false
  919. }
  920. bases = append(bases, prefix)
  921. }
  922. for _, a := range addrs {
  923. host := wireguardHostAddr(a)
  924. if !host.IsValid() {
  925. return false
  926. }
  927. fits := false
  928. for _, prefix := range bases {
  929. if prefix.Contains(host) {
  930. fits = true
  931. break
  932. }
  933. }
  934. if !fits {
  935. return false
  936. }
  937. }
  938. return true
  939. }
  940. // Attach applies the client to every requested inbound: one failing inbound no
  941. // longer aborts the others, so the error can name several and needRestart holds.
  942. func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
  943. existing, err := s.GetByID(id)
  944. if err != nil {
  945. return false, err
  946. }
  947. currentIds, err := s.GetInboundIdsForRecord(id)
  948. if err != nil {
  949. return false, err
  950. }
  951. have := make(map[int]struct{}, len(currentIds))
  952. for _, x := range currentIds {
  953. have[x] = struct{}{}
  954. }
  955. clientWire := existing.ToClient()
  956. flow, ffErr := s.EffectiveFlow(nil, id)
  957. if ffErr != nil {
  958. return false, ffErr
  959. }
  960. clientWire.Flow = flow
  961. clientWire.UpdatedAt = time.Now().UnixMilli()
  962. // If this identity has no CURRENT WireGuard/AmneziaWG attachment,
  963. // clientWire.AllowedIPs (from the ClientRecord) is a leftover from
  964. // whenever it last had one -- nothing reserves it anymore. Clear it so
  965. // attaching to a tunnel inbound now allocates a fresh address instead
  966. // of resurrecting the old one, which may no longer even be the lowest
  967. // free slot. Left untouched when the identity already has an active
  968. // tunnel elsewhere, so extending it to a second protocol still keeps
  969. // the same address on both.
  970. if !s.hasTunnelAttachment(inboundSvc, currentIds) {
  971. clientWire.AllowedIPs = nil
  972. }
  973. adds := make([]*model.Inbound, 0, len(inboundIds))
  974. for _, ibId := range inboundIds {
  975. if _, attached := have[ibId]; attached {
  976. continue
  977. }
  978. inbound, getErr := inboundSvc.GetInbound(ibId)
  979. if getErr != nil {
  980. return false, fmt.Errorf("inbound %d: %w", ibId, getErr)
  981. }
  982. copyClient := *clientWire
  983. if !addressesFitAmneziaWGInbound(copyClient.AllowedIPs, inbound) {
  984. copyClient.AllowedIPs = nil
  985. }
  986. if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
  987. return false, fmt.Errorf("inbound %d: %w", ibId, err)
  988. }
  989. settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}})
  990. if mErr != nil {
  991. return false, fmt.Errorf("inbound %d: %w", ibId, mErr)
  992. }
  993. adds = append(adds, &model.Inbound{Id: ibId, Settings: string(settingsPayload)})
  994. }
  995. return s.fanoutInboundClientAdds(inboundSvc, adds)
  996. }
  997. func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
  998. return s.Create(inboundSvc, &ClientCreatePayload{
  999. Client: client,
  1000. InboundIds: []int{inboundId},
  1001. })
  1002. }
  1003. func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
  1004. if email == "" {
  1005. return false, common.NewError("client email is required")
  1006. }
  1007. rec, err := s.GetRecordByEmail(nil, email)
  1008. if err != nil {
  1009. return false, err
  1010. }
  1011. return s.Detach(inboundSvc, rec.Id, []int{inboundId})
  1012. }
  1013. func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
  1014. if email == "" {
  1015. return false, common.NewError("client email is required")
  1016. }
  1017. rec, err := s.GetRecordByEmail(nil, email)
  1018. if err != nil {
  1019. return false, err
  1020. }
  1021. return s.Attach(inboundSvc, rec.Id, inboundIds)
  1022. }
  1023. func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
  1024. if email == "" {
  1025. return false, common.NewError("client email is required")
  1026. }
  1027. rec, err := s.GetRecordByEmail(nil, email)
  1028. if err != nil {
  1029. return false, err
  1030. }
  1031. return s.Detach(inboundSvc, rec.Id, inboundIds)
  1032. }
  1033. func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
  1034. if email == "" {
  1035. return false, common.NewError("client email is required")
  1036. }
  1037. rec, err := s.GetRecordByEmail(nil, email)
  1038. if err == nil {
  1039. return s.Delete(inboundSvc, rec.Id, keepTraffic)
  1040. }
  1041. if !errors.Is(err, gorm.ErrRecordNotFound) {
  1042. return false, err
  1043. }
  1044. inboundIds, idsErr := s.findInboundIdsByClientEmail(email)
  1045. if idsErr != nil {
  1046. return false, idsErr
  1047. }
  1048. if len(inboundIds) == 0 {
  1049. return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
  1050. }
  1051. applies := make([]inboundApply, 0, len(inboundIds))
  1052. for _, ibId := range inboundIds {
  1053. applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
  1054. nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, keepTraffic, true)
  1055. if errors.Is(delErr, ErrClientNotInInbound) {
  1056. return nr, nil
  1057. }
  1058. return nr, delErr
  1059. }})
  1060. }
  1061. needRestart, delErr := fanoutInboundApplies(applies)
  1062. if delErr != nil {
  1063. return needRestart, delErr
  1064. }
  1065. if !keepTraffic {
  1066. db := database.GetDB()
  1067. if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
  1068. return needRestart, err
  1069. }
  1070. if err := clearGlobalTraffic(db, email); err != nil {
  1071. return needRestart, err
  1072. }
  1073. if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil {
  1074. return needRestart, err
  1075. }
  1076. if err := db.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
  1077. return needRestart, err
  1078. }
  1079. }
  1080. return needRestart, nil
  1081. }
  1082. func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
  1083. if email == "" {
  1084. return false, common.NewError("client email is required")
  1085. }
  1086. rec, err := s.GetRecordByEmail(nil, email)
  1087. if err != nil {
  1088. return false, err
  1089. }
  1090. return s.Update(inboundSvc, rec.Id, updated, limitHwid, inboundFilter...)
  1091. }
  1092. func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
  1093. existing, err := s.GetByID(id)
  1094. if err != nil {
  1095. return false, err
  1096. }
  1097. currentIds, err := s.GetInboundIdsForRecord(id)
  1098. if err != nil {
  1099. return false, err
  1100. }
  1101. have := make(map[int]struct{}, len(currentIds))
  1102. for _, x := range currentIds {
  1103. have[x] = struct{}{}
  1104. }
  1105. applies := make([]inboundApply, 0, len(inboundIds))
  1106. for _, ibId := range inboundIds {
  1107. if _, attached := have[ibId]; !attached {
  1108. continue
  1109. }
  1110. if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
  1111. return false, getErr
  1112. }
  1113. // Detach by email — the client's stable identity (see Delete).
  1114. if existing.Email == "" {
  1115. continue
  1116. }
  1117. applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
  1118. nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, true, false)
  1119. if errors.Is(delErr, ErrClientNotInInbound) {
  1120. return nr, nil
  1121. }
  1122. return nr, delErr
  1123. }})
  1124. }
  1125. return fanoutInboundApplies(applies)
  1126. }