mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-05 12:24:20 +00:00
82600936d6
* fix(flow): restore XTLS Vision when an inbound becomes flow-eligible clientWithInboundFlow strips Vision from a VLESS client whenever the target inbound is not flow-eligible at client-write time — e.g. an XHTTP inbound before its vlessenc (ML-KEM) encryption is set, or a client attached to such an inbound. Nothing restored the flow once the inbound later became eligible: an inbound edit stores its settings verbatim and never re-gates the clients. So enabling encryption on an existing XHTTP inbound left every client without flow, and the generated configs, share links and subscriptions silently dropped flow=xtls-rprx-vision — most visibly on node inbounds and on any inbound where encryption was turned on after the clients existed. Restore the flow at the two points where an inbound can become eligible: - UpdateInbound: after the new stream/settings are final, re-add Vision to clients that currently carry no flow but whose intended flow (their flow_override on a sibling inbound, via EffectiveFlowByEmail) is Vision — only when the inbound is now flow-eligible. - MigrationRestoreVisionFlow: a one-time, idempotent boot migration that applies the same repair to existing installs and refreshes flow_override via SyncInbound. The repair is conservative: it never invents a flow for a client that has none anywhere, never overwrites an explicit flow, and is a no-op on healthy installs. Adds EffectiveFlowByEmail and a unit test covering keep/skip/no-op cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(flow): serialize restored settings with MarshalIndent Match the indented JSON used by the adjacent timestamp block in UpdateInbound and the externalProxy migration, so a restored inbound's settings column keeps the same multi-line format as everything else (review nit on #5520). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(flow): batch the intended-flow lookup and run it on the active tx restoreVisionFlowForEligibleInbound resolved each empty-flow client's intended flow with EffectiveFlowByEmail, which issued two queries per client (GetRecordByEmail + EffectiveFlow). A client that genuinely uses no Vision keeps an empty flow forever, so it was re-queried on every UpdateInbound and every boot — O(clients) queries per save on a Reality/TCP or XHTTP+vlessenc inbound carrying many non-Vision clients, executed inside the serialized writer transaction. Replace it with EffectiveFlowsByEmails: collect every empty-flow email first and resolve them in a single batched join over client_inbounds + clients (lowest inbound_id wins, same rule as before), chunked for the SQLite bind-var limit. Also thread the active tx through restoreVisionFlowForEligibleInbound so the read runs on the writer's own connection while it holds the lock instead of a separate pooled connection (UpdateInbound passes its tx; the boot migration passes nil → GetDB() as before). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
6.5 KiB
Go
228 lines
6.5 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
row := &model.ClientRecord{}
|
|
err := tx.Where("email = ?", email).First(row).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// EffectiveFlow returns the client's flow from the first flow-capable inbound
|
|
// it is attached to (lowest inbound_id with a non-empty flow_override). The
|
|
// canonical clients.Flow column is unreliable for multi-inbound clients: a
|
|
// non-flow inbound (Hysteria, WS, gRPC, …) carries an empty flow and, when its
|
|
// SyncInbound runs last, overwrites the column to "" even though a VLESS Reality
|
|
// inbound stored a real flow. The per-inbound flow_override is always correct,
|
|
// so derive the display flow from it (order-independent). See issue #4792.
|
|
func (s *ClientService) EffectiveFlow(tx *gorm.DB, recordId int) (string, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
var flows []string
|
|
err := tx.Model(&model.ClientInbound{}).
|
|
Where("client_id = ? AND flow_override <> ?", recordId, "").
|
|
Order("inbound_id ASC").
|
|
Limit(1).
|
|
Pluck("flow_override", &flows).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(flows) == 0 {
|
|
return "", nil
|
|
}
|
|
return flows[0], nil
|
|
}
|
|
|
|
// EffectiveFlowsByEmails resolves the intended flow (non-empty flow_override,
|
|
// lowest inbound_id first — same rule as EffectiveFlow) for many clients in one
|
|
// query, keyed by email. Emails absent from the result carry no flow anywhere.
|
|
// Batched so flow restoration on an inbound with many clients is O(1) queries
|
|
// instead of O(clients). Used to restore a stripped flow onto an inbound that
|
|
// has just become flow-eligible.
|
|
func (s *ClientService) EffectiveFlowsByEmails(tx *gorm.DB, emails []string) (map[string]string, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
out := make(map[string]string, len(emails))
|
|
if len(emails) == 0 {
|
|
return out, nil
|
|
}
|
|
type row struct {
|
|
Email string
|
|
Flow string `gorm:"column:flow_override"`
|
|
}
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var rows []row
|
|
err := tx.Table("client_inbounds").
|
|
Select("clients.email AS email, client_inbounds.flow_override AS flow_override").
|
|
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
|
Where("clients.email IN ? AND client_inbounds.flow_override <> ?", batch, "").
|
|
Order("client_inbounds.inbound_id ASC").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
if _, seen := out[r.Email]; !seen { // ordered by inbound_id ASC → first = lowest
|
|
out[r.Email] = r.Flow
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
var ids []int
|
|
err := tx.Table("client_inbounds").
|
|
Select("client_inbounds.inbound_id").
|
|
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
|
Where("clients.email = ?", email).
|
|
Scan(&ids).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
|
row := &model.ClientRecord{}
|
|
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
|
|
var ids []int
|
|
err := database.GetDB().Table("client_inbounds").
|
|
Where("client_id = ?", id).
|
|
Order("inbound_id ASC").
|
|
Pluck("inbound_id", &ids).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *ClientService) List() ([]ClientWithAttachments, error) {
|
|
db := database.GetDB()
|
|
var rows []model.ClientRecord
|
|
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return []ClientWithAttachments{}, nil
|
|
}
|
|
|
|
clientIds := make([]int, 0, len(rows))
|
|
emails := make([]string, 0, len(rows))
|
|
for i := range rows {
|
|
clientIds = append(clientIds, rows[i].Id)
|
|
if rows[i].Email != "" {
|
|
emails = append(emails, rows[i].Email)
|
|
}
|
|
}
|
|
|
|
attachments := make(map[int][]int, len(rows))
|
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
|
var links []model.ClientInbound
|
|
if err := db.Where("client_id IN ?", batch).Find(&links).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, l := range links {
|
|
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
|
}
|
|
}
|
|
|
|
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
|
if len(emails) > 0 {
|
|
var stats []xray.ClientTraffic
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var batchStats []xray.ClientTraffic
|
|
if err := db.Where("email IN ?", batch).Find(&batchStats).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
stats = append(stats, batchStats...)
|
|
}
|
|
overlayGlobalTrafficValues(db, stats)
|
|
for i := range stats {
|
|
trafficByEmail[stats[i].Email] = &stats[i]
|
|
}
|
|
}
|
|
|
|
out := make([]ClientWithAttachments, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, ClientWithAttachments{
|
|
ClientRecord: rows[i],
|
|
InboundIds: attachments[rows[i].Id],
|
|
Traffic: trafficByEmail[rows[i].Email],
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) bool {
|
|
if strings.TrimSpace(email) == "" {
|
|
return false
|
|
}
|
|
ids, err := s.GetInboundIdsForEmail(nil, email)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return inboundSvc.AnyNodePending(ids)
|
|
}
|
|
|
|
// findInboundIdsByClientEmail returns every inbound whose settings.clients[]
|
|
// JSON contains an entry with the given email. Driver-portable (no JSON
|
|
// operators) by parsing in Go — fine for the rare fallback path.
|
|
func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) {
|
|
var inbounds []model.Inbound
|
|
if err := database.GetDB().
|
|
Select("id, settings").
|
|
Where("settings LIKE ?", "%"+email+"%").
|
|
Find(&inbounds).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]int, 0, len(inbounds))
|
|
for _, ib := range inbounds {
|
|
var settings map[string]any
|
|
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
|
continue
|
|
}
|
|
clients, ok := settings["clients"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, c := range clients {
|
|
cm, ok := c.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cEmail, _ := cm["email"].(string); cEmail == email {
|
|
out = append(out, ib.Id)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|