Files
3x-ui/internal/web/service/client_link.go
T
MHSanaei 8b525b5be6 fix(node): clear a client's orphan mark when it is attached again
A client with an inbound link is not an orphan, but the node sweep's
mark was cleared only at the end of a node merge. An admin attach left
a stale mark, and ReapSyncOrphans deleted the client at its next
detach, traffic and IPs included, without the grace window.

Inserting a link now clears the mark of the clients it links, and the
reaper rereads its candidates inside its writer, so an attach that
commits after the list was read is not reaped.
2026-11-01 13:30:53 +01:00

651 lines
20 KiB
Go

package service
import (
"fmt"
"reflect"
"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
}
// nil preserves the stored binding, empty clears it via Update's write.
if ids := model.NormalizeClientHostRuleIds(incoming.ClientHostRuleIds); ids != nil {
row.ClientHostRuleIds = ids
}
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
if incoming.KeepAlive != "" {
row.KeepAlive = incoming.KeepAlive
}
// Guarded like Group: only an AmneziaWG entry is sure to carry the spec, and the record
// is shared with every other inbound of the client.
if incoming.ForwardedPorts != "" {
row.ForwardedPorts = incoming.ForwardedPorts
}
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", "node_id").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, inbound.NodeID, 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
incoming.ClientHostRuleIds = model.NormalizeClientHostRuleIds(incoming.ClientHostRuleIds)
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
// DeepEqual: ClientRecord now carries a slice (ClientHostRuleIds)
// and is no longer ==-comparable.
if reflect.DeepEqual(*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(&current).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
}
// Not left to the next node merge: there may never be one, and a mark that outlives the
// attach hands the client to the reaper on its next detach.
linked := make([]int, 0, len(toInsert))
for _, link := range toInsert {
linked = append(linked, link.ClientId)
}
for _, batch := range chunkInts(linked, sqlInChunk) {
if err := tx.Model(&model.ClientRecord{}).Where("id IN ? AND sync_orphaned_at > 0", batch).
Updates(map[string]any{"sync_orphaned_at": 0, "sync_orphaned_by": 0}).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 higher-ranked WireGuard/AmneziaWG
// inbound, which owns their record's peer fields: a master-local one first, then the lowest id.
func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, nodeID *int, clientIds []int) (map[int]struct{}, error) {
outranks := "inbounds.node_id IS NULL AND client_inbounds.inbound_id < ?"
if nodeID != nil {
outranks = "(inbounds.node_id IS NULL OR client_inbounds.inbound_id < ?)"
}
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 inbounds.protocol IN ?",
batch, []model.Protocol{model.WireGuard, model.AmneziaWG}).
Where(outranks, inboundId).
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
}
type clientStatsIdentity struct {
UUID string
Email string
SubID string
}
func (s *ClientService) listStatsIdentitiesForInbounds(tx *gorm.DB, inboundIDs []int) (map[int][]clientStatsIdentity, error) {
if tx == nil {
tx = database.GetDB()
}
out := make(map[int][]clientStatsIdentity)
for _, batch := range chunkInts(uniqueInts(inboundIDs), sqlInChunk) {
var rows []struct {
InboundID int `gorm:"column:inbound_id"`
UUID string `gorm:"column:uuid"`
Email string `gorm:"column:email"`
SubID string `gorm:"column:sub_id"`
}
err := tx.Table("clients").
Select("client_inbounds.inbound_id, clients.uuid, clients.email, clients.sub_id").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id IN ?", batch).
Order("client_inbounds.inbound_id ASC, clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
out[row.InboundID] = append(out[row.InboundID], clientStatsIdentity{
UUID: row.UUID, Email: row.Email, SubID: row.SubID,
})
}
}
return out, nil
}
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
}