From ad56b1bbd7e3896127ede3d1cc3a1f24d8ce1cfa Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 18 May 2026 00:29:20 +0200 Subject: [PATCH] fix(clients): preserve ClientRecord on inbound delete + filter Attached inbounds to multi-client protocols Replace the global orphan sweep in setRemoteTrafficLocked with a per-inbound diff cleanup: only delete a ClientRecord whose email disappeared from a snap-tracked inbound (i.e. a node-side delete). Inbounds that vanished entirely from the snap (e.g. admin deleted the inbound on master) aren't iterated, so a client whose last attachment came from that inbound is now left alone instead of being deleted alongside the inbound. ClientFormModal and ClientBulkAddModal now filter the Attached inbounds dropdown to protocols that actually support multiple clients: shadowsocks, vless, vmess, trojan, hysteria, hysteria2, and portfallback (which routes through VLESS settings). Co-Authored-By: Claude Opus 4.7 --- .../src/pages/clients/ClientBulkAddModal.vue | 14 +++-- .../src/pages/clients/ClientFormModal.vue | 16 +++-- web/service/inbound.go | 62 +++++++++++++++---- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/clients/ClientBulkAddModal.vue b/frontend/src/pages/clients/ClientBulkAddModal.vue index ad7d4fc8..799c1827 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.vue +++ b/frontend/src/pages/clients/ClientBulkAddModal.vue @@ -72,11 +72,17 @@ const delayedExpireDays = computed({ set: (days) => { form.expiryTime = -86400000 * (days || 0); }, }); +const MULTI_CLIENT_PROTOCOLS = new Set([ + 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2', 'portfallback', +]); + const inboundOptions = computed(() => - (props.inbounds || []).map((ib) => ({ - label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, - value: ib.id, - })), + (props.inbounds || []) + .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol)) + .map((ib) => ({ + label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, + value: ib.id, + })), ); watch(() => props.open, (next) => { diff --git a/frontend/src/pages/clients/ClientFormModal.vue b/frontend/src/pages/clients/ClientFormModal.vue index b0553150..6c2244ea 100644 --- a/frontend/src/pages/clients/ClientFormModal.vue +++ b/frontend/src/pages/clients/ClientFormModal.vue @@ -85,12 +85,18 @@ function gbToBytes(gb) { return Math.round(gb * 1024 * 1024 * 1024); } +const MULTI_CLIENT_PROTOCOLS = new Set([ + 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2', 'portfallback', +]); + const inboundOptions = computed(() => - (props.inbounds || []).map((ib) => ({ - label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, - value: ib.id, - title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`, - })), + (props.inbounds || []) + .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol)) + .map((ib) => ({ + label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, + value: ib.id, + title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`, + })), ); const flowCapableIds = computed(() => { diff --git a/web/service/inbound.go b/web/service/inbound.go index 35b65084..1db5f930 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1277,6 +1277,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } + type oldSet struct { + inboundID int + emails map[string]struct{} + } + var perInboundOld []oldSet for _, snapIb := range snap.Inbounds { if snapIb == nil { continue @@ -1285,6 +1290,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if !ok { continue } + var oldEmailsRows []string + if err := tx.Table("clients"). + Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). + Where("client_inbounds.inbound_id = ?", c.Id). + Pluck("email", &oldEmailsRows).Error; err == nil { + oldEmails := make(map[string]struct{}, len(oldEmailsRows)) + for _, e := range oldEmailsRows { + if e != "" { + oldEmails[e] = struct{}{} + } + } + perInboundOld = append(perInboundOld, oldSet{inboundID: c.Id, emails: oldEmails}) + } clients, gcErr := s.GetClients(snapIb) if gcErr != nil { @@ -1310,20 +1328,40 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } - var orphanEmails []string - if err := tx.Table("clients"). - Joins("LEFT JOIN client_inbounds ON client_inbounds.client_id = clients.id"). - Where("client_inbounds.client_id IS NULL"). - Pluck("clients.email", &orphanEmails).Error; err != nil { - logger.Warning("setRemoteTraffic: orphan sweep query failed:", err) - } else if len(orphanEmails) > 0 { - if err := tx.Where("email IN ?", orphanEmails).Delete(&model.ClientRecord{}).Error; err != nil { - logger.Warning("setRemoteTraffic: orphan sweep delete ClientRecord failed:", err) + for _, old := range perInboundOld { + var stillAttached []string + if err := tx.Table("clients"). + Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). + Where("client_inbounds.inbound_id = ?", old.inboundID). + Pluck("email", &stillAttached).Error; err != nil { + continue } - if err := tx.Where("email IN ?", orphanEmails).Delete(&xray.ClientTraffic{}).Error; err != nil { - logger.Warning("setRemoteTraffic: orphan sweep delete ClientTraffic failed:", err) + stillSet := make(map[string]struct{}, len(stillAttached)) + for _, e := range stillAttached { + stillSet[e] = struct{}{} + } + for email := range old.emails { + if _, kept := stillSet[email]; kept { + continue + } + var attachmentCount int64 + if err := tx.Table("client_inbounds"). + Joins("JOIN clients ON clients.id = client_inbounds.client_id"). + Where("clients.email = ?", email). + Count(&attachmentCount).Error; err != nil { + continue + } + if attachmentCount > 0 { + continue + } + if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil { + logger.Warning("setRemoteTraffic: delete ClientRecord", email, "failed:", err) + } + if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil { + logger.Warning("setRemoteTraffic: delete ClientTraffic", email, "failed:", err) + } + structuralChange = true } - structuralChange = true } if err := tx.Commit().Error; err != nil {