mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 23:05:01 +03:00
47be06358e
Deleting a client tombstones its email for 90s, so a node snapshot taken before the delete reached the node cannot resurrect it. Client create and bulk create withdrew that tombstone, but a client re-created through InboundService.AddInbound (SyncInbound / syncInboundClients) did not. On a master, re-adding a node inbound with a just-deleted email therefore let the next node merge filter the new client out of the snapshot, unlink it from its inbound and mark it a sync orphan; ReapSyncOrphans then hard-deleted it 15 minutes later and the node lost it. syncInboundClients now records the emails it creates on the serialized transaction, and runSerializedTx withdraws their tombstones only after the commit, still on the writer, so no merge runs in between and a rolled-back create leaves the tombstone standing. The per-call withdraws in Create and bulkCreate are covered by this and removed. Co-authored-by: FZ1010 <FZ1010@users.noreply.github.com>
583 lines
18 KiB
Go
583 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// applyClientRecordMerge merges incoming client-record fields onto row using the
|
|
// same rules everywhere a client record is persisted: scalar quota / lifecycle /
|
|
// subscription fields are applied unconditionally (so clearing them takes
|
|
// effect), while credentials and identifiers are only overwritten when the
|
|
// incoming value is non-empty (so a partial update preserves the stored UUID /
|
|
// password / keys). CreatedAt keeps the earliest known value. Email, UpdatedAt,
|
|
// and the Id primary key are intentionally not touched here — callers handle
|
|
// those separately. Shared by SyncInbound (per-inbound persistence) and Update
|
|
// (the no-attached-inbound fallback) so the two paths cannot diverge.
|
|
func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecord) {
|
|
if incoming.UUID != "" {
|
|
row.UUID = incoming.UUID
|
|
}
|
|
if incoming.Password != "" {
|
|
row.Password = incoming.Password
|
|
}
|
|
if incoming.Auth != "" {
|
|
row.Auth = incoming.Auth
|
|
}
|
|
if incoming.Secret != "" {
|
|
row.Secret = incoming.Secret
|
|
}
|
|
if incoming.AdTag != "" {
|
|
row.AdTag = incoming.AdTag
|
|
}
|
|
row.Flow = incoming.Flow
|
|
if incoming.Security != "" {
|
|
row.Security = incoming.Security
|
|
}
|
|
if incoming.Reverse != "" {
|
|
row.Reverse = incoming.Reverse
|
|
}
|
|
if incoming.PrivateKey != "" {
|
|
row.PrivateKey = incoming.PrivateKey
|
|
}
|
|
if incoming.PublicKey != "" {
|
|
row.PublicKey = incoming.PublicKey
|
|
}
|
|
if incoming.AllowedIPs != "" {
|
|
row.AllowedIPs = incoming.AllowedIPs
|
|
}
|
|
row.PreSharedKey = incoming.PreSharedKey
|
|
row.KeepAlive = incoming.KeepAlive
|
|
row.SubID = incoming.SubID
|
|
row.LimitIP = incoming.LimitIP
|
|
row.TotalGB = incoming.TotalGB
|
|
row.ExpiryTime = incoming.ExpiryTime
|
|
row.Enable = incoming.Enable
|
|
row.TgID = incoming.TgID
|
|
if incoming.Group != "" {
|
|
row.Group = incoming.Group
|
|
}
|
|
row.Comment = incoming.Comment
|
|
row.Reset = incoming.Reset
|
|
row.ResetDay = incoming.ResetDay
|
|
row.ResetWeekday = incoming.ResetWeekday
|
|
row.ResetMax = incoming.ResetMax
|
|
// Guarded like Group and AdTag: a node snapshot rebuilt from settings that
|
|
// predate the cycle would otherwise silently erase it.
|
|
if incoming.TrafficReset != "" {
|
|
row.TrafficReset = incoming.TrafficReset
|
|
}
|
|
if incoming.TrafficResetDay > 0 {
|
|
row.TrafficResetDay = incoming.TrafficResetDay
|
|
}
|
|
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
|
|
row.CreatedAt = incoming.CreatedAt
|
|
}
|
|
}
|
|
|
|
// SyncInbound makes the inbound's client records and links match clients
|
|
// exactly: links for clients no longer in the set are removed.
|
|
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
|
return s.syncInboundClients(tx, inboundId, clients, nil, true)
|
|
}
|
|
|
|
// ApplyInboundClientDelta persists only the clients an edit actually changed
|
|
// plus the emails it detached, leaving every other link on the inbound alone —
|
|
// the whole point being that a one-client edit must not rewrite the inbound's
|
|
// entire membership set (#6252).
|
|
func (s *ClientService) ApplyInboundClientDelta(tx *gorm.DB, inboundId int, changed []model.Client, detachEmails []string) error {
|
|
return s.syncInboundClients(tx, inboundId, changed, detachEmails, false)
|
|
}
|
|
|
|
func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients []model.Client, detachEmails []string, prune bool) error {
|
|
if err := validateClientsRenewal(clients); err != nil {
|
|
return err
|
|
}
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
|
|
var inbound model.Inbound
|
|
if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil {
|
|
return err
|
|
}
|
|
|
|
emails := make([]string, 0, len(clients))
|
|
seen := make(map[string]struct{}, len(clients))
|
|
for i := range clients {
|
|
email := strings.TrimSpace(clients[i].Email)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[email]; ok {
|
|
continue
|
|
}
|
|
seen[email] = struct{}{}
|
|
emails = append(emails, email)
|
|
}
|
|
|
|
existing := make(map[string]*model.ClientRecord, len(emails))
|
|
const selectChunk = 400
|
|
for start := 0; start < len(emails); start += selectChunk {
|
|
end := min(start+selectChunk, len(emails))
|
|
var rows []model.ClientRecord
|
|
if err := tx.Where("email IN ?", emails[start:end]).Find(&rows).Error; err != nil {
|
|
return err
|
|
}
|
|
for i := range rows {
|
|
r := rows[i]
|
|
existing[r.Email] = &r
|
|
}
|
|
}
|
|
|
|
isTunnel := inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG
|
|
var onLowerTunnel map[int]struct{}
|
|
if isTunnel && len(existing) > 0 {
|
|
ids := make([]int, 0, len(existing))
|
|
for _, row := range existing {
|
|
ids = append(ids, row.Id)
|
|
}
|
|
var err error
|
|
if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
idByEmail := make(map[string]int, len(emails))
|
|
pending := make(map[string]*model.ClientRecord, len(emails))
|
|
toCreate := make([]*model.ClientRecord, 0, len(emails))
|
|
for i := range clients {
|
|
email := strings.TrimSpace(clients[i].Email)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
|
|
incoming := clients[i].ToRecord()
|
|
// ToRecord copies the raw email; store the trimmed key this function
|
|
// looks up by, or a padded email is inserted and never found again.
|
|
incoming.Email = email
|
|
row, ok := existing[email]
|
|
if !ok {
|
|
if _, dup := pending[email]; !dup {
|
|
pending[email] = incoming
|
|
toCreate = append(toCreate, incoming)
|
|
}
|
|
continue
|
|
}
|
|
|
|
before := *row
|
|
applyClientRecordMerge(row, incoming)
|
|
_, outranked := onLowerTunnel[row.Id]
|
|
keepLinkOwnedFields(row, &before, isTunnel && !outranked)
|
|
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
|
row.UpdatedAt = preservedUpdatedAt
|
|
|
|
idByEmail[email] = row.Id
|
|
|
|
if *row == before {
|
|
continue
|
|
}
|
|
if err := tx.Save(row).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ClientRecord{}).
|
|
Where("id = ?", row.Id).
|
|
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if len(toCreate) > 0 {
|
|
// Capture enable before Create: gorm default:true drops explicit false (#6478).
|
|
// Restate disabled rows after CreateInBatches.
|
|
wantEnable := make([]bool, len(toCreate))
|
|
for i, rec := range toCreate {
|
|
wantEnable[i] = rec.Enable
|
|
}
|
|
if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
|
|
return err
|
|
}
|
|
// Whichever API re-created the client, a delete tombstone left standing makes
|
|
// the next node merge prune it and ReapSyncOrphans delete it.
|
|
liftTombstonesOnCommit(tx, toCreate)
|
|
disabledIDs := make([]int, 0)
|
|
for i, rec := range toCreate {
|
|
idByEmail[rec.Email] = rec.Id
|
|
if !wantEnable[i] {
|
|
disabledIDs = append(disabledIDs, rec.Id)
|
|
}
|
|
}
|
|
for _, batch := range chunkInts(disabledIDs, sqlInChunk) {
|
|
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", batch).
|
|
UpdateColumn("enable", false).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
wantedFlow := make(map[int]string, len(clients))
|
|
wantedIds := make([]int, 0, len(clients))
|
|
for i := range clients {
|
|
email := strings.TrimSpace(clients[i].Email)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
id, ok := idByEmail[email]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, dup := wantedFlow[id]; dup {
|
|
continue
|
|
}
|
|
wantedFlow[id] = clients[i].Flow
|
|
wantedIds = append(wantedIds, id)
|
|
}
|
|
|
|
relinked, err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := settleRecordFlows(tx, relinked); err != nil {
|
|
return err
|
|
}
|
|
|
|
affected := map[int]struct{}{inboundId: {}}
|
|
for _, ids := range chunkInts(wantedIds, sqlInChunk) {
|
|
var inboundIDs []int
|
|
if err := tx.Model(&model.ClientInbound{}).Distinct("inbound_id").Where("client_id IN ?", ids).Pluck("inbound_id", &inboundIDs).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, id := range inboundIDs {
|
|
affected[id] = struct{}{}
|
|
}
|
|
}
|
|
affectedIDs := make([]int, 0, len(affected))
|
|
for id := range affected {
|
|
affectedIDs = append(affectedIDs, id)
|
|
}
|
|
for _, ids := range chunkInts(affectedIDs, sqlInChunk) {
|
|
var duplicates int64
|
|
err := tx.Raw(`SELECT COUNT(*) FROM (
|
|
SELECT ci.inbound_id, LOWER(c.uuid) FROM clients c
|
|
JOIN client_inbounds ci ON ci.client_id = c.id
|
|
JOIN inbounds i ON i.id = ci.inbound_id
|
|
WHERE i.protocol = ? AND c.uuid <> '' AND ci.inbound_id IN ?
|
|
GROUP BY ci.inbound_id, LOWER(c.uuid) HAVING COUNT(*) > 1
|
|
) AS duplicate_uuids`, model.TUIC, ids).Scan(&duplicates).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if duplicates > 0 {
|
|
return fmt.Errorf("TUIC: duplicate client UUID within inbound")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// reconcileInboundLinks writes only the client_inbounds rows that differ and returns
|
|
// their clients. prune also removes links absent from wantedFlow, as only a full sync may.
|
|
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) ([]int, error) {
|
|
var current []model.ClientInbound
|
|
if prune {
|
|
if err := tx.Where("inbound_id = ?", inboundId).Find(¤t).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
|
|
var rows []model.ClientInbound
|
|
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
current = append(current, rows...)
|
|
}
|
|
}
|
|
|
|
var toDelete []int
|
|
toUpdate := make(map[string][]int)
|
|
have := make(map[int]struct{}, len(current))
|
|
for _, link := range current {
|
|
have[link.ClientId] = struct{}{}
|
|
flow, keep := wantedFlow[link.ClientId]
|
|
if !keep {
|
|
if prune {
|
|
toDelete = append(toDelete, link.ClientId)
|
|
}
|
|
continue
|
|
}
|
|
// Plain compare, not non-empty-wins: clearing a flow must persist "".
|
|
if flow != link.FlowOverride {
|
|
toUpdate[flow] = append(toUpdate[flow], link.ClientId)
|
|
}
|
|
}
|
|
|
|
if len(detachEmails) > 0 {
|
|
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
|
|
var ids []int
|
|
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
if _, keep := wantedFlow[id]; !keep {
|
|
toDelete = append(toDelete, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
toInsert := make([]model.ClientInbound, 0, len(wantedIds))
|
|
for _, id := range wantedIds {
|
|
if _, exists := have[id]; exists {
|
|
continue
|
|
}
|
|
toInsert = append(toInsert, model.ClientInbound{
|
|
ClientId: id,
|
|
InboundId: inboundId,
|
|
FlowOverride: wantedFlow[id],
|
|
})
|
|
}
|
|
|
|
for _, batch := range chunkInts(toDelete, sqlInChunk) {
|
|
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
|
Delete(&model.ClientInbound{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for flow, ids := range toUpdate {
|
|
for _, batch := range chunkInts(ids, sqlInChunk) {
|
|
if err := tx.Model(&model.ClientInbound{}).
|
|
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
|
Update("flow_override", flow).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if len(toInsert) > 0 {
|
|
// The delete this replaced also serialized concurrent syncs of one
|
|
// inbound; without the clause a racing node poll aborts its whole tx.
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
|
|
}).CreateInBatches(toInsert, 200).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
relinked := append([]int(nil), toDelete...)
|
|
for _, ids := range toUpdate {
|
|
relinked = append(relinked, ids...)
|
|
}
|
|
for _, link := range toInsert {
|
|
relinked = append(relinked, link.ClientId)
|
|
}
|
|
return relinked, nil
|
|
}
|
|
|
|
// keepLinkOwnedFields undoes the merge of the record fields one inbound cannot decide
|
|
// alone, or syncing a client's inbounds in turn rewrites the record back and forth.
|
|
func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) {
|
|
row.Flow = before.Flow
|
|
if ownsTunnelPeer {
|
|
return
|
|
}
|
|
row.PrivateKey = before.PrivateKey
|
|
row.PublicKey = before.PublicKey
|
|
row.AllowedIPs = before.AllowedIPs
|
|
row.PreSharedKey = before.PreSharedKey
|
|
row.KeepAlive = before.KeepAlive
|
|
}
|
|
|
|
// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG
|
|
// inbound with a lower id than inboundId: that tunnel owns their record's peer fields.
|
|
func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) {
|
|
out := make(map[int]struct{})
|
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
|
var ids []int
|
|
if err := tx.Table("client_inbounds").
|
|
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
|
|
Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?",
|
|
batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}).
|
|
Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
out[id] = struct{}{}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// settleRecordFlows sets the record flow of each still-linked client to its
|
|
// effective flow (EffectiveFlow's rule); an unlinked record keeps its own.
|
|
func settleRecordFlows(tx *gorm.DB, clientIds []int) error {
|
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
|
var links []model.ClientInbound
|
|
if err := tx.Select("client_id", "flow_override").
|
|
Where("client_id IN ?", batch).
|
|
Order("inbound_id ASC").
|
|
Find(&links).Error; err != nil {
|
|
return err
|
|
}
|
|
effective := make(map[int]string, len(batch))
|
|
for _, link := range links {
|
|
if flow, seen := effective[link.ClientId]; !seen || (flow == "" && link.FlowOverride != "") {
|
|
effective[link.ClientId] = link.FlowOverride
|
|
}
|
|
}
|
|
var records []model.ClientRecord
|
|
if err := tx.Select("id", "flow").Where("id IN ?", batch).Find(&records).Error; err != nil {
|
|
return err
|
|
}
|
|
idsByFlow := make(map[string][]int)
|
|
for _, rec := range records {
|
|
if flow, linked := effective[rec.Id]; linked && flow != rec.Flow {
|
|
idsByFlow[flow] = append(idsByFlow[flow], rec.Id)
|
|
}
|
|
}
|
|
for flow, ids := range idsByFlow {
|
|
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", ids).
|
|
UpdateColumn("flow", flow).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error
|
|
}
|
|
|
|
func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
type joinedRow struct {
|
|
model.ClientRecord
|
|
FlowOverride string
|
|
}
|
|
var rows []joinedRow
|
|
err := tx.Table("clients").
|
|
Select("clients.*, client_inbounds.flow_override AS flow_override").
|
|
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
|
Where("client_inbounds.inbound_id = ?", inboundId).
|
|
Order("clients.id ASC").
|
|
Find(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]model.Client, 0, len(rows))
|
|
for i := range rows {
|
|
c := rows[i].ToClient()
|
|
c.Flow = rows[i].FlowOverride
|
|
out = append(out, *c)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListForInboundBySubId is ListForInbound narrowed to one subscription id —
|
|
// both filter columns are indexed, so the subscription server resolves a
|
|
// subscriber's clients without touching the inbound's settings JSON.
|
|
func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId string) ([]model.Client, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
type joinedRow struct {
|
|
model.ClientRecord
|
|
FlowOverride string
|
|
}
|
|
var rows []joinedRow
|
|
err := tx.Table("clients").
|
|
Select("clients.*, client_inbounds.flow_override AS flow_override").
|
|
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
|
Where("client_inbounds.inbound_id = ? AND clients.sub_id = ?", inboundId, subId).
|
|
Order("clients.id ASC").
|
|
Find(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]model.Client, 0, len(rows))
|
|
for i := range rows {
|
|
c := rows[i].ToClient()
|
|
c.Flow = rows[i].FlowOverride
|
|
out = append(out, *c)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// validateProtocolClients runs inside the write every path linking clients to an inbound
|
|
// shares, so a client the protocol's core config would refuse never reaches it.
|
|
func (s *ClientService) validateProtocolClients(tx *gorm.DB, protocol model.Protocol, inboundID int, changed []model.Client, detached []string, prune bool) error {
|
|
switch protocol {
|
|
case model.TUIC:
|
|
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
|
|
case model.MASQUE:
|
|
return validateMasqueEmails(changed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MASQUE sends the email as the Basic-auth user, so xray-core refuses the whole inbound,
|
|
// and with it the Xray start, over one email holding ":".
|
|
func validateMasqueEmails(clients []model.Client) error {
|
|
for _, client := range clients {
|
|
if strings.Contains(client.Email, ":") {
|
|
return fmt.Errorf("MASQUE: client email %q cannot contain \":\"", client.Email)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ClientService) validateTuicIdentities(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
|
|
candidates := append([]model.Client(nil), changed...)
|
|
if !prune {
|
|
current, err := s.ListForInbound(tx, inboundID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
excluded := make(map[string]bool)
|
|
for _, client := range changed {
|
|
excluded[client.Email] = true
|
|
}
|
|
for _, email := range detached {
|
|
excluded[email] = true
|
|
}
|
|
for _, client := range current {
|
|
if !excluded[client.Email] {
|
|
candidates = append(candidates, client)
|
|
}
|
|
}
|
|
}
|
|
seen := make(map[uuid.UUID]string)
|
|
for _, client := range candidates {
|
|
if client.ID == "" {
|
|
continue
|
|
}
|
|
id, err := uuid.Parse(client.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("TUIC: invalid client UUID")
|
|
}
|
|
if email, exists := seen[id]; exists && email != client.Email {
|
|
return fmt.Errorf("TUIC: duplicate client UUID within inbound")
|
|
}
|
|
seen[id] = client.Email
|
|
}
|
|
return nil
|
|
}
|