mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 01:47:15 +00:00
878ee839db
A client attached to both WireGuard and AmneziaWG shared one AllowedIPs form field with a dynamically-switching label, so its two genuinely different addresses could never both be shown or edited correctly. Worse, Update/Create broadcast that one shared value to every attached wg/awg inbound with no subnet-fit check, so an ordinary edit save could silently overwrite one protocol's address with the other's -- the same bug class already fixed for Attach, but reachable from any client edit. model.Client gains an optional AllowedIPsByInbound map so a caller can send distinct values per inbound; Update/Create honor it and, when it's absent, clear a shared value that doesn't fit an AmneziaWG inbound's own subnet instead of writing it through. A new TunnelAllowedIPsByInbound read path feeds the real per-inbound address to the client edit form via GET, which now renders two separate, correctly-labeled fields whenever both protocols are attached (unchanged single dynamic field otherwise).
273 lines
8.1 KiB
Go
273 lines
8.1 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
|
|
if tgId <= 0 {
|
|
return nil, errors.New("tg_id must be a positive integer")
|
|
}
|
|
var rows []*model.ClientRecord
|
|
err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error
|
|
return rows, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// TunnelAllowedIPsByInbound returns, for each given WireGuard/AmneziaWG
|
|
// inbound id, the real AllowedIPs this email currently has on that specific
|
|
// inbound's own settings JSON -- joined comma-separated, matching the form
|
|
// value shape a single AllowedIPs field already uses. Non-tunnel inbounds
|
|
// and ids the email isn't actually attached to are simply absent from the
|
|
// result (not an error): callers use this to seed a per-protocol display
|
|
// field, and ClientRecord's own single AllowedIPs column can't tell two
|
|
// different protocol addresses apart, which is exactly the gap this closes.
|
|
func (s *ClientService) TunnelAllowedIPsByInbound(inboundSvc *InboundService, email string, inboundIds []int) (map[int]string, error) {
|
|
result := make(map[int]string, len(inboundIds))
|
|
for _, ibId := range inboundIds {
|
|
inbound, err := inboundSvc.GetInbound(ibId)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
if inbound.Protocol != model.WireGuard && inbound.Protocol != model.AmneziaWG {
|
|
continue
|
|
}
|
|
clients, err := inboundSvc.GetClients(inbound)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range clients {
|
|
if strings.EqualFold(clients[i].Email, email) {
|
|
result[ibId] = strings.Join(clients[i].AllowedIPs, ",")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return result, 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
|
|
}
|