mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
af466b6a24
A master's per-node sync must scope what it sends to the clients that node serves, so its cost tracks the node and not the fleet. The global-usage push already did (node_client_traffics by node_id); the 10s client-IP push sent GetAllInboundClientIps, the whole table, to every node. Each node's MergeInboundClientIps then created a row for every foreign email, and its next GET clientIps echoed the whole fleet back. Its IP-limit job only ever reads rows for its own clients, so none of it was used. With 150 nodes x 150 clients, one IP tick pushed 299 MB and pulled 264 MB, every node held 22,500 rows instead of 150, and sync ticks grew 3.8s -> 10.2s even at 1ms latency; the cost grows with the square of the fleet. Both pushes now share nodeHostedEmails. After the change the same fleet moves 2.0 MB / 1.8 MB per tick and ticks stay near 3.2s. Nodes upgraded with foreign rows shed them within 30 minutes via pruneStaleIpRows.
302 lines
9.8 KiB
Go
302 lines
9.8 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, error) {
|
|
db := database.GetDB()
|
|
var ips []model.InboundClientIps
|
|
err := db.Model(&model.InboundClientIps{}).Find(&ips).Error
|
|
return ips, err
|
|
}
|
|
|
|
// nodeHostedEmails is every client one node serves, its descendants' included.
|
|
// Per-node pushes are scoped to it so their cost tracks the node, not the fleet.
|
|
func nodeHostedEmails(db *gorm.DB, nodeID int) ([]string, error) {
|
|
var emails []string
|
|
err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", nodeID).Pluck("email", &emails).Error
|
|
return emails, err
|
|
}
|
|
|
|
// GetNodeInboundClientIps returns the IP rows of the clients nodeID hosts: a node's
|
|
// IP-limit job reads no other row, so pushing the rest only made it echo them back.
|
|
func (s *InboundService) GetNodeInboundClientIps(nodeID int) ([]model.InboundClientIps, error) {
|
|
db := database.GetDB()
|
|
emails, err := nodeHostedEmails(db, nodeID)
|
|
if err != nil || len(emails) == 0 {
|
|
return nil, err
|
|
}
|
|
var ips []model.InboundClientIps
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var page []model.InboundClientIps
|
|
if err := db.Where("client_email IN ?", batch).Find(&page).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
ips = append(ips, page...)
|
|
}
|
|
return ips, nil
|
|
}
|
|
|
|
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
|
|
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
|
|
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
|
|
// a node has already pruned (otherwise the merge defeats the eviction cluster-wide).
|
|
const clientIpStaleAfterSeconds = int64(30 * 60)
|
|
|
|
// clientIpEntry is the on-disk shape of each element of InboundClientIps.Ips. Tags
|
|
// match job.IPWithTimestamp so the blob round-trips with the access.log scanner.
|
|
type clientIpEntry struct {
|
|
IP string `json:"ip"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
// mergeClientIpEntries unions old and incoming IP observations, dropping anything
|
|
// older than cutoff, keeping the most recent timestamp per IP, and returning the
|
|
// result sorted newest-first.
|
|
func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientIpEntry {
|
|
ipMap := make(map[string]int64, len(old)+len(incoming))
|
|
for _, e := range old {
|
|
if e.Timestamp < cutoff {
|
|
continue
|
|
}
|
|
ipMap[e.IP] = e.Timestamp
|
|
}
|
|
for _, e := range incoming {
|
|
if e.Timestamp < cutoff {
|
|
continue
|
|
}
|
|
if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur {
|
|
ipMap[e.IP] = e.Timestamp
|
|
}
|
|
}
|
|
out := make([]clientIpEntry, 0, len(ipMap))
|
|
for ip, ts := range ipMap {
|
|
out = append(out, clientIpEntry{IP: ip, Timestamp: ts})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
|
|
return out
|
|
}
|
|
|
|
// MergeInboundClientIps folds client IPs synced from another node into the local
|
|
// inbound_client_ips table without double-counting an IP seen on multiple nodes and
|
|
// without resurrecting stale entries. Existing rows are updated in place; brand-new
|
|
// clients (typically node-only clients with no local row) are created with a fresh
|
|
// local id.
|
|
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
|
|
db := database.GetDB()
|
|
var currentIps []model.InboundClientIps
|
|
if err := db.Model(&model.InboundClientIps{}).Find(¤tIps).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
|
|
for i := range currentIps {
|
|
currentMap[currentIps[i].ClientEmail] = ¤tIps[i]
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
cutoff := now - clientIpStaleAfterSeconds
|
|
|
|
// Node syncs run concurrently (one goroutine per node) and shared clients
|
|
// appear in several nodes' reports. Locking rows in each node's arbitrary
|
|
// report order lets two merges grab the same rows in opposite order, which
|
|
// Postgres aborts as a deadlock (40P01) — take them in one global order.
|
|
sort.Slice(incomingIps, func(i, j int) bool {
|
|
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
|
|
})
|
|
|
|
tx := db.Begin()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
for _, incoming := range incomingIps {
|
|
if incoming.ClientEmail == "" || incoming.Ips == "" {
|
|
continue
|
|
}
|
|
|
|
var incomingEntries []clientIpEntry
|
|
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
|
|
|
|
current, exists := currentMap[incoming.ClientEmail]
|
|
if !exists {
|
|
// New client we've never seen locally. Drop stale entries up front and
|
|
// skip the row entirely if nothing is fresh, so we don't persist a row
|
|
// that is dead on arrival.
|
|
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
|
|
if len(fresh) == 0 {
|
|
continue
|
|
}
|
|
b, _ := json.Marshal(fresh)
|
|
incoming.Ips = string(b)
|
|
// Never carry the remote node's primary key into the local table: id
|
|
// spaces are independent across nodes and the remote id would collide
|
|
// with an unrelated local row. OnConflict guards the race where
|
|
// check_client_ip_job creates the same brand-new email between the
|
|
// snapshot above and this insert.
|
|
incoming.Id = 0
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "client_email"}},
|
|
DoNothing: true,
|
|
}).Create(&incoming).Error; err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
var oldEntries []clientIpEntry
|
|
if current.Ips != "" {
|
|
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
|
|
}
|
|
|
|
merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff)
|
|
b, _ := json.Marshal(merged)
|
|
mergedStr := string(b)
|
|
|
|
// A concurrent check_client_ip_job db.Save on the same row can interleave
|
|
// with this update (benign last-writer-wins; any dropped IP reappears on the
|
|
// next scan/sync), so only write when the blob actually changed.
|
|
if current.Ips != mergedStr {
|
|
if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return tx.Commit().Error
|
|
}
|
|
|
|
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
|
|
// The caller only renames onto a free identity, so a row already sitting on
|
|
// newEmail is stale tracking data — drop it instead of failing the edit.
|
|
if oldEmail != newEmail {
|
|
if err := tx.Where("client_email = ?", newEmail).Delete(model.InboundClientIps{}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Model(model.InboundClientIps{}).Where("client_email = ?", oldEmail).Update("client_email", newEmail).Error
|
|
}
|
|
|
|
func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
|
|
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
|
|
}
|
|
|
|
func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
|
|
const chunk = 400
|
|
for start := 0; start < len(emails); start += chunk {
|
|
end := min(start+chunk, len(emails))
|
|
if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error) {
|
|
db := database.GetDB()
|
|
InboundClientIps := &model.InboundClientIps{}
|
|
err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if InboundClientIps.Ips == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// Try to parse as new format (with timestamps)
|
|
type IPWithTimestamp struct {
|
|
IP string `json:"ip"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
var ipsWithTime []IPWithTimestamp
|
|
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
|
|
|
|
// If successfully parsed as new format, return with timestamps
|
|
if err == nil && len(ipsWithTime) > 0 {
|
|
return InboundClientIps.Ips, nil
|
|
}
|
|
|
|
// Otherwise, assume it's old format (simple string array)
|
|
// Try to parse as simple array and convert to new format
|
|
var oldIps []string
|
|
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
|
|
if err == nil && len(oldIps) > 0 {
|
|
// Convert old format to new format with current timestamp
|
|
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
|
|
for i, ip := range oldIps {
|
|
newIpsWithTime[i] = IPWithTimestamp{
|
|
IP: ip,
|
|
Timestamp: time.Now().Unix(),
|
|
}
|
|
}
|
|
result, _ := json.Marshal(newIpsWithTime)
|
|
return string(result), nil
|
|
}
|
|
|
|
// Return as-is if parsing fails
|
|
return InboundClientIps.Ips, nil
|
|
}
|
|
|
|
func (s *InboundService) ClearClientIps(clientEmail string) error {
|
|
db := database.GetDB()
|
|
|
|
result := db.Model(model.InboundClientIps{}).
|
|
Where("client_email = ?", clientEmail).
|
|
Update("ips", "")
|
|
err := result.Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PruneStaleClientIps enforces clientIpStaleAfterSeconds for rows the online
|
|
// scan no longer rewrites: an offline client's addresses must still expire.
|
|
func (s *InboundService) PruneStaleClientIps() error {
|
|
db := database.GetDB()
|
|
cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
|
|
|
|
var rows []model.InboundClientIps
|
|
if err := db.Find(&rows).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, row := range rows {
|
|
var entries []clientIpEntry
|
|
if row.Ips != "" {
|
|
// Legacy blobs without timestamps stay untouched; the next scan rewrites them.
|
|
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
kept := mergeClientIpEntries(nil, entries, cutoff)
|
|
if len(kept) == 0 {
|
|
if err := db.Delete(&model.InboundClientIps{}, row.Id).Error; err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if len(kept) == len(entries) {
|
|
continue
|
|
}
|
|
b, _ := json.Marshal(kept)
|
|
if err := db.Model(&model.InboundClientIps{}).Where("id = ?", row.Id).Update("ips", string(b)).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return pruneStaleNodeClientIps(cutoff)
|
|
}
|