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/clause" ) // node_client_ips.go implements per-node client-IP attribution. The flat // inbound_client_ips table is a cluster-wide union (used for IP-limit counting // and pushed back to every node), so it cannot tell which node a given IP is // on. NodeClientIp keeps that attribution: each panel records its own Xray // observations under its panelGuid, and the master merges every node's // guid-keyed report — never mixing in IPs a parent pushed down. // mergeModelClientIpEntries unions old and incoming observations, drops anything // older than cutoff, keeps the newest timestamp per IP, and sorts newest-first. // It mirrors mergeClientIpEntries but operates on the exported wire type. func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry { ipMap := make(map[string]int64, len(old)+len(incoming)) for _, e := range old { if e.IP == "" || e.Timestamp < cutoff { continue } ipMap[e.IP] = e.Timestamp } for _, e := range incoming { if e.IP == "" || e.Timestamp < cutoff { continue } if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur { ipMap[e.IP] = e.Timestamp } } out := make([]model.ClientIpEntry, 0, len(ipMap)) for ip, ts := range ipMap { out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts}) } sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp }) return out } // upsertNodeClientIps folds a guid's per-email observations into NodeClientIp, // merging with whatever is already stored for that (guid, email) and dropping // stale entries. Empty merged results delete the row so the table stays bounded. func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry) error { if guid == "" || len(perEmail) == 0 { return nil } db := database.GetDB() cutoff := time.Now().Unix() - clientIpStaleAfterSeconds var existing []model.NodeClientIp if err := db.Where("node_guid = ?", guid).Find(&existing).Error; err != nil { return err } existingByEmail := make(map[string]*model.NodeClientIp, len(existing)) for i := range existing { existingByEmail[existing[i].Email] = &existing[i] } tx := db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() for email, incoming := range perEmail { if email == "" { continue } var old []model.ClientIpEntry if cur, ok := existingByEmail[email]; ok && cur.Ips != "" { _ = json.Unmarshal([]byte(cur.Ips), &old) } merged := mergeModelClientIpEntries(old, incoming, cutoff) if len(merged) == 0 { // Nothing fresh: drop any stale row so attribution doesn't linger. if _, ok := existingByEmail[email]; ok { if err := tx.Where("node_guid = ? AND email = ?", guid, email). Delete(&model.NodeClientIp{}).Error; err != nil { tx.Rollback() return err } } continue } b, _ := json.Marshal(merged) row := model.NodeClientIp{NodeGuid: guid, Email: email, Ips: string(b)} if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "node_guid"}, {Name: "email"}}, DoUpdates: clause.AssignmentColumns([]string{"ips"}), }).Create(&row).Error; err != nil { tx.Rollback() return err } } return tx.Commit().Error } // RecordLocalClientIps stores this panel's own Xray observations under its // panelGuid. Called by check_client_ip_job each scan with the live per-email IPs // the local core reported. func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[string][]model.ClientIpEntry) error { return upsertNodeClientIps(panelGuid, observed) } // MergeClientIpsByGuid folds a node's guid-keyed attribution report (its own // panelGuid subtree plus any descendants) into the local table, preserving which // physical node each IP is on across a chain. func (s *InboundService) MergeClientIpsByGuid(trees map[string]map[string][]model.ClientIpEntry) error { for guid, perEmail := range trees { if err := upsertNodeClientIps(guid, perEmail); err != nil { return err } } return nil } // GetClientIpsByGuid returns this panel's full attribution subtree (guid -> email // -> fresh IPs), dropping stale entries. It is what the clientIpsByGuid endpoint // serves to a parent panel. func (s *InboundService) GetClientIpsByGuid() (map[string]map[string][]model.ClientIpEntry, error) { db := database.GetDB() var rows []model.NodeClientIp if err := db.Find(&rows).Error; err != nil { return nil, err } cutoff := time.Now().Unix() - clientIpStaleAfterSeconds out := make(map[string]map[string][]model.ClientIpEntry) for _, row := range rows { if row.NodeGuid == "" || row.Email == "" || row.Ips == "" { continue } var entries []model.ClientIpEntry if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil { continue } fresh := mergeModelClientIpEntries(nil, entries, cutoff) if len(fresh) == 0 { continue } if out[row.NodeGuid] == nil { out[row.NodeGuid] = make(map[string][]model.ClientIpEntry) } out[row.NodeGuid][row.Email] = fresh } return out, nil } // GetClientIpNodeAttribution returns, for one client email, a map of IP -> the // guid that most recently observed it (within the stale window). Used to label // each IP in the panel with the node it is connecting to. func (s *InboundService) GetClientIpNodeAttribution(email string) (map[string]string, error) { db := database.GetDB() var rows []model.NodeClientIp if err := db.Where("email = ?", email).Find(&rows).Error; err != nil { return nil, err } cutoff := time.Now().Unix() - clientIpStaleAfterSeconds ipGuid := make(map[string]string) ipTs := make(map[string]int64) for _, row := range rows { if row.NodeGuid == "" || row.Ips == "" { continue } var entries []model.ClientIpEntry if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil { continue } for _, e := range entries { if e.IP == "" || e.Timestamp < cutoff { continue } if cur, ok := ipTs[e.IP]; !ok || e.Timestamp > cur { ipTs[e.IP] = e.Timestamp ipGuid[e.IP] = row.NodeGuid } } } return ipGuid, nil } // ClientIpInfo is one IP shown in the panel's per-client IP log, labelled with // the node it is connecting through ("" = this local panel). type ClientIpInfo struct { IP string `json:"ip"` Time string `json:"time"` Node string `json:"node"` } // GetClientIpsWithNodes returns a client's recorded IPs (from the flat // inbound_client_ips display set) annotated with the node each IP is on, using // the per-node attribution table. Local IPs (and any IP without attribution) // carry an empty Node. func (s *InboundService) GetClientIpsWithNodes(email string) ([]ClientIpInfo, error) { raw, err := s.GetInboundClientIps(email) if err != nil || raw == "" { // Record-not-found (or empty) is "no IPs", not an error for the UI. return []ClientIpInfo{}, nil } var entries []model.ClientIpEntry if jerr := json.Unmarshal([]byte(raw), &entries); jerr != nil || len(entries) == 0 { // Legacy shape: a plain JSON array of IP strings. var oldIps []string if json.Unmarshal([]byte(raw), &oldIps) == nil { entries = entries[:0] for _, ip := range oldIps { entries = append(entries, model.ClientIpEntry{IP: ip}) } } } if len(entries) == 0 { return []ClientIpInfo{}, nil } attr, _ := s.GetClientIpNodeAttribution(email) guidName := s.nodeGuidNameMap() localGuid, _ := (&SettingService{}).GetPanelGuid() out := make([]ClientIpInfo, 0, len(entries)) for _, e := range entries { if e.IP == "" { continue } info := ClientIpInfo{IP: e.IP} if e.Timestamp > 0 { info.Time = time.Unix(e.Timestamp, 0).Local().Format("2006-01-02 15:04:05") } if guid, ok := attr[e.IP]; ok && guid != "" && guid != localGuid { info.Node = guidName[guid] } out = append(out, info) } return out, nil } // nodeGuidNameMap maps each known node's stable guid to its display name. func (s *InboundService) nodeGuidNameMap() map[string]string { db := database.GetDB() var nodes []model.Node if err := db.Model(&model.Node{}).Find(&nodes).Error; err != nil { return map[string]string{} } m := make(map[string]string, len(nodes)) for _, n := range nodes { if n.Guid != "" { m[n.Guid] = n.Name } } return m } // DeleteNodeClientIpsByGuid removes all attribution rows for a guid (e.g. when a // node is deleted) so its IPs stop being reported and counted. func (s *InboundService) DeleteNodeClientIpsByGuid(guid string) error { if guid == "" { return nil } db := database.GetDB() return db.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error }