package repo import ( "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "log" "net" "os" "path/filepath" "sort" "strconv" "strings" "time" gsqlite "github.com/glebarez/sqlite" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/clause" "gorm.io/gorm/logger" "go-backend/internal/security" "go-backend/internal/store/model" ) const ( defaultPostgresMaxOpenConns = 32 defaultPostgresMaxIdleConns = 8 defaultPostgresConnMaxIdle = 5 * time.Minute defaultPostgresConnMaxLife = 30 * time.Minute ) // ─── Type aliases for backward compatibility ───────────────────────── // Handlers still reference repo.User, repo.BackupData, etc. type User = model.User type ViteConfig = model.ViteConfig type Announcement = model.Announcement type UserTunnelDetail = model.UserTunnelDetail type UserForwardDetail = model.UserForwardDetail type StatisticsFlow = model.StatisticsFlow type Node = model.Node type PeerShare = model.PeerShare type PeerShareRuntime = model.PeerShareRuntime type FederationTunnelBinding = model.FederationTunnelBinding type BackupData = model.BackupData type UserBackup = model.UserBackup type NodeBackup = model.NodeBackup type TunnelBackup = model.TunnelBackup type ChainTunnelBackup = model.ChainTunnelBackup type ForwardBackup = model.ForwardBackup type ForwardPortBackup = model.ForwardPortBackup type UserTunnelBackup = model.UserTunnelBackup type SpeedLimitBackup = model.SpeedLimitBackup type TunnelGroupBackup = model.TunnelGroupBackup type UserGroupBackup = model.UserGroupBackup type PermissionBackup = model.PermissionBackup type PermissionGrantBackup = model.PermissionGrantBackup type ImportResult = model.ImportResult type NodeMetric = model.NodeMetric type TunnelMetric = model.TunnelMetric type ServiceMonitor = model.ServiceMonitor type ServiceMonitorResult = model.ServiceMonitorResult type TunnelQuality = model.TunnelQuality // ─── Repository ────────────────────────────────────────────────────── type Repository struct { db *gorm.DB dbPath string } type FlowUploadCounterDelta struct { ForwardID int64 UserID int64 UserTunnelID int64 InFlow int64 OutFlow int64 } func (r *Repository) DB() *gorm.DB { if r == nil { return nil } return r.db } func sortedFlowUploadTargetIDs(totals map[int64][2]int64) []int64 { ids := make([]int64, 0, len(totals)) for id := range totals { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return ids } func (r *Repository) ApplyFlowUploadDeltasBatch(deltas []FlowUploadCounterDelta) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } if len(deltas) == 0 { return nil } return r.db.Transaction(func(tx *gorm.DB) error { return applyFlowUploadDeltasTx(tx, deltas) }) } func applyFlowUploadDeltasTx(tx *gorm.DB, deltas []FlowUploadCounterDelta) error { if tx == nil { return errors.New("database unavailable") } if len(deltas) == 0 { return nil } forwardTotals := make(map[int64][2]int64, len(deltas)) userTotals := make(map[int64][2]int64, len(deltas)) userTunnelTotals := make(map[int64][2]int64, len(deltas)) for _, delta := range deltas { if delta.ForwardID > 0 { current := forwardTotals[delta.ForwardID] current[0] += delta.InFlow current[1] += delta.OutFlow forwardTotals[delta.ForwardID] = current } if delta.UserID > 0 { current := userTotals[delta.UserID] current[0] += delta.InFlow current[1] += delta.OutFlow userTotals[delta.UserID] = current } if delta.UserTunnelID > 0 { current := userTunnelTotals[delta.UserTunnelID] current[0] += delta.InFlow current[1] += delta.OutFlow userTunnelTotals[delta.UserTunnelID] = current } } for _, forwardID := range sortedFlowUploadTargetIDs(forwardTotals) { total := forwardTotals[forwardID] if err := tx.Model(&model.Forward{}).Where("id = ?", forwardID).UpdateColumns(map[string]interface{}{ "in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1]), }).Error; err != nil { return err } } for _, userID := range sortedFlowUploadTargetIDs(userTotals) { total := userTotals[userID] if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumns(map[string]interface{}{ "in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1]), }).Error; err != nil { return err } } for _, userTunnelID := range sortedFlowUploadTargetIDs(userTunnelTotals) { total := userTunnelTotals[userTunnelID] if err := tx.Model(&model.UserTunnel{}).Where("id = ?", userTunnelID).UpdateColumns(map[string]interface{}{ "in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1]), }).Error; err != nil { return err } } return nil } // ─── Open / Close ──────────────────────────────────────────────────── func Open(path string) (*Repository, error) { if err := ensureParentDir(path); err != nil { return nil, err } dsn := "file:" + path + "?_pragma=busy_timeout(5000)" + "&_pragma=journal_mode(WAL)" + "&_pragma=synchronous(NORMAL)" db, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return nil, err } sqlDB, err := db.DB() if err != nil { return nil, err } sqlDB.SetMaxOpenConns(1) if err := prepareSQLiteLegacyColumns(db); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("prepare sqlite legacy schema: %w", err) } if err := autoMigrateAll(db); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("auto migrate: %w", err) } seedData(db) if err := migrateSchema(db); err != nil { _ = sqlDB.Close() return nil, err } return &Repository{db: db, dbPath: path}, nil } func OpenPostgres(dsn string) (*Repository, error) { if strings.TrimSpace(dsn) == "" { return nil, fmt.Errorf("empty postgres dsn") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return nil, err } sqlDB, err := db.DB() if err != nil { return nil, err } configurePostgresPool(sqlDB) if err := sqlDB.Ping(); err != nil { _ = sqlDB.Close() return nil, err } if err := preparePostgresLegacySchema(db); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("prepare postgres legacy schema: %w", err) } if err := autoMigrateAll(db); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("auto migrate: %w", err) } seedData(db) if err := migrateSchema(db); err != nil { _ = sqlDB.Close() return nil, err } return &Repository{db: db}, nil } func configurePostgresPool(sqlDB *sql.DB) { if sqlDB == nil { return } sqlDB.SetMaxOpenConns(defaultPostgresMaxOpenConns) sqlDB.SetMaxIdleConns(defaultPostgresMaxIdleConns) sqlDB.SetConnMaxIdleTime(defaultPostgresConnMaxIdle) sqlDB.SetConnMaxLifetime(defaultPostgresConnMaxLife) } func (r *Repository) Close() error { if r == nil || r.db == nil { return nil } sqlDB, err := r.db.DB() if err != nil { return err } return sqlDB.Close() } func autoMigrateAll(db *gorm.DB) error { if db.Dialector.Name() == "sqlite" { if err := prepareSQLiteNftablesColumns(db); err != nil { return err } } models := []interface{}{ &model.User{}, &model.UserQuota{}, &model.Forward{}, &model.ForwardPort{}, &model.Node{}, &model.NodeSSHConfig{}, &model.NftRuleBinding{}, &model.NftCounterState{}, &model.SpeedLimit{}, &model.StatisticsFlow{}, &model.Tunnel{}, &model.ChainTunnel{}, &model.UserTunnel{}, &model.TunnelGroup{}, &model.UserGroup{}, &model.TunnelGroupTunnel{}, &model.UserGroupUser{}, &model.GroupPermission{}, &model.GroupPermissionGrant{}, &model.MonitorPermission{}, &model.ViteConfig{}, &model.PeerShare{}, &model.PeerShareRuntime{}, &model.FederationTunnelBinding{}, &model.Announcement{}, &model.SchemaVersion{}, &model.NodeMetric{}, &model.TunnelMetric{}, &model.ServiceMonitor{}, &model.ServiceMonitorResult{}, &model.TunnelQuality{}, } if db.Dialector.Name() != "sqlite" { return db.AutoMigrate(models...) } m := db.Migrator() hasNode := m.HasTable(&model.Node{}) hasTunnel := m.HasTable(&model.Tunnel{}) hasForward := m.HasTable(&model.Forward{}) for _, item := range models { if hasNode { if _, ok := item.(*model.Node); ok { continue } } if hasTunnel { if _, ok := item.(*model.Tunnel); ok { continue } } if hasForward { if _, ok := item.(*model.Forward); ok { continue } } if err := db.AutoMigrate(item); err != nil { return err } } return nil } // preparePostgresLegacySchema renames unique constraints that were created by // the old schema.sql (which used inline UNIQUE column syntax) to the names // expected by GORM's NamingStrategy ("uni__"). Without this, // GORM's AutoMigrate emits "DROP CONSTRAINT uni_..." against constraints that // don't exist under that name, crashing startup on upgraded PostgreSQL installs. func preparePostgresLegacySchema(db *gorm.DB) error { if db == nil || db.Dialector.Name() != "postgres" { return nil } type rename struct{ table, oldName, newName string } renames := []rename{ {"vite_config", "vite_config_name_key", "uni_vite_config_name"}, {"peer_share", "peer_share_token_key", "uni_peer_share_token"}, {"peer_share_runtime", "peer_share_runtime_reservation_id_key", "uni_peer_share_runtime_reservation_id"}, {"peer_share_runtime", "peer_share_runtime_resource_key_key", "uni_peer_share_runtime_resource_key"}, {"federation_tunnel_binding", "federation_tunnel_binding_resource_key_key", "uni_federation_tunnel_binding_resource_key"}, } for _, r := range renames { var count int64 if err := db.Raw( `SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND table_name = ? AND constraint_name = ? AND constraint_type = 'UNIQUE'`, r.table, r.oldName, ).Scan(&count).Error; err != nil { return fmt.Errorf("check constraint %s.%s: %w", r.table, r.oldName, err) } if count == 0 { continue } if err := db.Exec( fmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName), ).Error; err != nil { return fmt.Errorf("rename constraint %s.%s→%s: %w", r.table, r.oldName, r.newName, err) } } return nil } func prepareSQLiteLegacyColumns(db *gorm.DB) error { if db == nil || db.Dialector.Name() != "sqlite" { return nil } m := db.Migrator() if m.HasTable(&model.Node{}) { for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "ExpiryTime", "RenewalCycle", "ExpiryReminderDismissed"} { if m.HasColumn(&model.Node{}, field) { continue } if err := m.AddColumn(&model.Node{}, field); err != nil { return fmt.Errorf("add node.%s: %w", field, err) } } } if m.HasTable(&model.Tunnel{}) { for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort"} { if m.HasColumn(&model.Tunnel{}, field) { continue } if err := m.AddColumn(&model.Tunnel{}, field); err != nil { return fmt.Errorf("add tunnel.%s: %w", field, err) } } } if m.HasTable(&model.Forward{}) { for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol", "ProxyProtocolReceive", "ProxyProtocolSend"} { if m.HasColumn(&model.Forward{}, field) { continue } if err := m.AddColumn(&model.Forward{}, field); err != nil { return fmt.Errorf("add forward.%s: %w", field, err) } } } return nil } func prepareSQLiteNftablesColumns(db *gorm.DB) error { if db == nil || db.Dialector.Name() != "sqlite" { return nil } if !db.Migrator().HasTable(&model.Node{}) { return nil } if !db.Migrator().HasColumn(&model.Node{}, "forward_mode") { if err := db.Exec("ALTER TABLE node ADD COLUMN forward_mode varchar(20) NOT NULL DEFAULT 'agent'").Error; err != nil { return err } } return nil } func seedData(db *gorm.DB) { var adminCount int64 if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 { adminPwd, err := security.HashPassword("admin_user") if err != nil { log.Printf("seed admin password hash failed: %v", err) } else { adminUser := model.User{ ID: 1, User: "admin_user", Pwd: adminPwd, RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0, FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000, UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1, PasswordChangedAt: 1748914865000, } db.Create(&adminUser) } } appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000} db.Where("id = ?", 1).FirstOrCreate(&appNameConfig) } // ─── User Queries ──────────────────────────────────────────────────── func (r *Repository) GetUserByUsername(username string) (*model.User, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var user model.User err := r.db.Where(`"user" = ?`, username).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &user, nil } func (r *Repository) GetUserByID(id int64) (*model.User, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var user model.User err := r.db.Where("id = ?", id).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &user, nil } func (r *Repository) UsernameExists(username string) (bool, error) { var count int64 err := r.db.Model(&model.User{}).Where(`"user" = ?`, username).Count(&count).Error if err != nil { return false, err } return count > 0, nil } func (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) { if r == nil || r.db == nil { return false, errors.New("repository not initialized") } var count int64 err := r.db.Model(&model.User{}).Where(`"user" = ? AND id != ?`, username, exceptID).Count(&count).Error if err != nil { return false, err } return count > 0, nil } func (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ "user": username, "pwd": passwordMD5, "password_changed_at": now, "updated_time": now, }).Error } // ─── Config Queries ────────────────────────────────────────────────── func (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var cfg model.ViteConfig err := r.db.Where("name = ?", name).First(&cfg).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &cfg, nil } func (r *Repository) ListConfigs() (map[string]string, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var configs []model.ViteConfig if err := r.db.Find(&configs).Error; err != nil { return nil, err } result := make(map[string]string) for _, c := range configs { result[c.Name] = c.Value } return result, nil } func (r *Repository) GetConfigsByNames(names []string) (map[string]string, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } if len(names) == 0 { return map[string]string{}, nil } var configs []model.ViteConfig if err := r.db.Select("name", "value").Where("name IN ?", names).Find(&configs).Error; err != nil { return nil, err } result := make(map[string]string, len(configs)) for _, c := range configs { result[c.Name] = c.Value } return result, nil } func (r *Repository) UpsertConfig(name, value string, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "name"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "time"}), }).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error } // ─── Announcement Queries ──────────────────────────────────────────── func (r *Repository) GetAnnouncement() (*model.Announcement, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var ann model.Announcement err := r.db.Order("id DESC").First(&ann).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &ann, nil } func (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } var count int64 if err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil { return err } if count == 0 { return r.db.Create(&model.Announcement{ Content: content, Enabled: enabled, CreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, }).Error } return r.db.Model(&model.Announcement{}).Where("1=1").Updates(map[string]interface{}{ "content": content, "enabled": enabled, "updated_time": now, }).Error } // ─── User Package Queries ──────────────────────────────────────────── func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var items []model.UserTunnelDetail err := r.db.Model(&model.UserTunnel{}). Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed"). Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id"). Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id"). Where("user_tunnel.user_id = ?", userID). Order("user_tunnel.id ASC"). Find(&items).Error if err != nil { return nil, err } if items == nil { items = make([]model.UserTunnelDetail, 0) } return items, nil } func (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } type fwdRow struct { ID int64 Name string TunnelID int64 TunnelName string RemoteAddr string InFlow int64 OutFlow int64 Status int CreatedAt int64 } var rows []fwdRow err := r.db.Model(&model.Forward{}). Select("forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at"). Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id"). Where("forward.user_id = ?", userID). Order("forward.id ASC"). Find(&rows).Error if err != nil { return nil, err } items := make([]model.UserForwardDetail, 0, len(rows)) for _, row := range rows { inIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID) if err != nil { return nil, err } items = append(items, model.UserForwardDetail{ ID: row.ID, Name: row.Name, TunnelID: row.TunnelID, TunnelName: row.TunnelName, InIP: inIP, InPort: inPort, RemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow, Status: row.Status, CreatedAt: row.CreatedAt, }) } return items, nil } // ─── Statistics Queries ────────────────────────────────────────────── func (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var items []model.StatisticsFlow err := r.db.Where("user_id = ?", userID).Order("id DESC").Limit(limit).Find(&items).Error if err != nil { return nil, err } if items == nil { items = make([]model.StatisticsFlow, 0) } return items, nil } // ─── Node Queries ──────────────────────────────────────────────────── func (r *Repository) NodeExistsBySecret(secret string) (bool, error) { if r == nil || r.db == nil { return false, errors.New("repository not initialized") } var count int64 err := r.db.Model(&model.Node{}).Where("secret = ?", secret).Count(&count).Error if err != nil { return false, err } return count > 0, nil } func (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var n model.Node err := r.db.Where("secret = ?", secret).First(&n).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &n, nil } func (r *Repository) GetNodeByID(id int64) (*model.Node, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var n model.Node err := r.db.Where("id = ?", id).First(&n).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &n, nil } func (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.Node{}).Where("id = ?", nodeID).Updates(map[string]interface{}{ "status": status, "version": version, "http": httpVal, "tls": tlsVal, "socks": socksVal, "updated_time": unixMilliNow(), }).Error } func (r *Repository) UpdateNodeStatus(nodeID int64, status int) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.Node{}).Where("id = ?", nodeID).Updates(map[string]interface{}{ "status": status, "updated_time": unixMilliNow(), }).Error } // ─── Flow ──────────────────────────────────────────────────────────── func (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&model.Forward{}).Where("id = ?", forwardID). UpdateColumns(map[string]interface{}{ "in_flow": gorm.Expr("in_flow + ?", inFlow), "out_flow": gorm.Expr("out_flow + ?", outFlow), }).Error; err != nil { return err } if err := tx.Model(&model.User{}).Where("id = ?", userID). UpdateColumns(map[string]interface{}{ "in_flow": gorm.Expr("in_flow + ?", inFlow), "out_flow": gorm.Expr("out_flow + ?", outFlow), }).Error; err != nil { return err } if userTunnelID > 0 { if err := tx.Model(&model.UserTunnel{}).Where("id = ?", userTunnelID). UpdateColumns(map[string]interface{}{ "in_flow": gorm.Expr("in_flow + ?", inFlow), "out_flow": gorm.Expr("out_flow + ?", outFlow), }).Error; err != nil { return err } } return nil }) } // ─── List Methods (return map[string]interface{}) ──────────────────── func (r *Repository) ListNodes() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var nodes []model.Node if err := r.db.Order("inx ASC, id ASC").Find(&nodes).Error; err != nil { return nil, err } nodeIDs := make([]int64, 0, len(nodes)) for _, n := range nodes { if defaultNodeForwardMode(n.ForwardMode) == "nftables" { nodeIDs = append(nodeIDs, n.ID) } } sshConfigByNodeID := make(map[int64]map[string]interface{}, len(nodeIDs)) if len(nodeIDs) > 0 { var configs []model.NodeSSHConfig if err := r.db.Where("node_id IN ?", nodeIDs).Find(&configs).Error; err != nil { return nil, err } for _, cfg := range configs { sshConfigByNodeID[cfg.NodeID] = map[string]interface{}{ "host": cfg.Host, "port": cfg.Port, "username": cfg.Username, "authType": cfg.AuthType, "sudoMode": cfg.SudoMode, } } } items := make([]map[string]interface{}, 0, len(nodes)) for _, n := range nodes { items = append(items, map[string]interface{}{ "id": n.ID, "inx": n.Inx, "name": n.Name, "remark": nullableString(n.Remark), "expiryTime": nullableInt64(n.ExpiryTime), "renewalCycle": nullableString(n.RenewalCycle), "ip": n.ServerIP, "serverIp": n.ServerIP, "serverIpV4": nullableString(n.ServerIPV4), "serverIpV6": nullableString(n.ServerIPV6), "extraIPs": nullableString(n.ExtraIPs), "port": n.Port, "tcpListenAddr": n.TCPListenAddr, "udpListenAddr": n.UDPListenAddr, "version": nullableString(n.Version), "http": n.HTTP, "tls": n.TLS, "socks": n.Socks, "status": n.Status, "isRemote": n.IsRemote, "forwardMode": defaultNodeForwardMode(n.ForwardMode), "remoteUrl": nullableString(n.RemoteURL), "remoteToken": nullableString(n.RemoteToken), "remoteConfig": nullableString(n.RemoteConfig), "expiryReminderDismissed": n.ExpiryReminderDismissed, "interfaceName": nullableString(n.InterfaceName), "sshConfig": sshConfigByNodeID[n.ID], }) } return items, nil } func (r *Repository) ListUsers() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var users []model.User if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil { return nil, err } userIDs := make([]int64, 0, len(users)) for _, u := range users { userIDs = append(userIDs, u.ID) } quotaMap, err := r.ListUserQuotaViewsByUserIDs(userIDs, time.Now()) if err != nil { return nil, err } items := make([]map[string]interface{}, 0, len(users)) for _, u := range users { item := map[string]interface{}{ "id": u.ID, "user": u.User, "name": u.User, "roleId": u.RoleID, "status": u.Status, "flow": u.Flow, "num": u.Num, "expTime": u.ExpTime, "flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime, "updatedTime": nullableInt64(u.UpdatedTime), "inFlow": u.InFlow, "outFlow": u.OutFlow, "maxConn": u.MaxConn, } if quota := quotaMap[u.ID]; quota != nil { item["dailyQuotaGB"] = quota.DailyLimitGB item["monthlyQuotaGB"] = quota.MonthlyLimitGB item["dailyUsedBytes"] = quota.DailyUsedBytes item["monthlyUsedBytes"] = quota.MonthlyUsedBytes item["disabledByQuota"] = quota.DisabledByQuota item["quotaDisabledAt"] = quota.DisabledAt } items = append(items, item) } return items, nil } func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var limits []model.SpeedLimit if err := r.db.Order("id DESC").Find(&limits).Error; err != nil { return nil, err } items := make([]map[string]interface{}, 0, len(limits)) for _, sl := range limits { item := map[string]interface{}{ "id": sl.ID, "name": sl.Name, "speed": sl.Speed, "status": sl.Status, "createdTime": sl.CreatedTime, "updatedTime": nullableInt64(sl.UpdatedTime), } items = append(items, item) } return items, nil } func (r *Repository) ListForwards() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } type fwdRow struct { ID int64 UserID int64 UserName string Name string TunnelID int64 TunnelName string TrafficRatio float64 RemoteAddr string Strategy string InFlow int64 OutFlow int64 CreatedTime int64 Status int Inx int SpeedID sql.NullInt64 MaxConn int IPMaxConn int IPSpeedID sql.NullInt64 IPSpeedLimitName string ProxyProtocol int ProxyProtocolReceive int ProxyProtocolSend int } var rows []fwdRow err := r.db.Model(&model.Forward{}). Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send"). Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id"). Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id"). Order("forward.inx ASC, forward.id ASC"). Find(&rows).Error if err != nil { return nil, err } items := make([]map[string]interface{}, 0, len(rows)) for _, row := range rows { proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(row.ProxyProtocol, row.ProxyProtocolReceive, row.ProxyProtocolSend) inIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID) if err != nil { return nil, err } item := map[string]interface{}{ "id": row.ID, "userId": row.UserID, "userName": row.UserName, "name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName, "tunnelTrafficRatio": row.TrafficRatio, "inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort), "remoteAddr": row.RemoteAddr, "strategy": row.Strategy, "inFlow": row.InFlow, "outFlow": row.OutFlow, "createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx), "maxConn": row.MaxConn, "ipMaxConn": row.IPMaxConn, "proxyProtocol": row.ProxyProtocol, "proxyProtocolReceive": proxyProtocolReceive, "proxyProtocolSend": proxyProtocolSend, } if row.SpeedID.Valid { item["speedId"] = row.SpeedID.Int64 } if row.IPSpeedID.Valid { item["ipSpeedId"] = row.IPSpeedID.Int64 } if strings.TrimSpace(row.IPSpeedLimitName) != "" { item["ipSpeedLimitName"] = row.IPSpeedLimitName } items = append(items, item) } return items, nil } func (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } type row struct { ID int64 Name string } var rows []row err := r.db.Model(&model.UserTunnel{}). Select("tunnel.id, tunnel.name"). Joins("JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id"). Where("user_tunnel.user_id = ? AND tunnel.status = 1", userID). Order("tunnel.inx ASC, tunnel.id ASC"). Find(&rows).Error if err != nil { return nil, err } tunnelIDs := make([]int64, 0, len(rows)) for _, rw := range rows { tunnelIDs = append(tunnelIDs, rw.ID) } portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs) items := make([]map[string]interface{}, 0, len(rows)) for _, rw := range rows { item := map[string]interface{}{"id": rw.ID, "name": rw.Name} if pr, ok := portRangeMap[rw.ID]; ok { item["portRangeMin"] = pr.min item["portRangeMax"] = pr.max } items = append(items, item) } return items, nil } func (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } type row struct { ID int64 Name string } var rows []row err := r.db.Model(&model.Tunnel{}).Select("id, name").Where("status = 1").Order("inx ASC, id ASC").Find(&rows).Error if err != nil { return nil, err } tunnelIDs := make([]int64, 0, len(rows)) for _, rw := range rows { tunnelIDs = append(tunnelIDs, rw.ID) } portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs) items := make([]map[string]interface{}, 0, len(rows)) for _, rw := range rows { item := map[string]interface{}{"id": rw.ID, "name": rw.Name} if pr, ok := portRangeMap[rw.ID]; ok { item["portRangeMin"] = pr.min item["portRangeMax"] = pr.max } items = append(items, item) } return items, nil } type tunnelPortRange struct { min int max int } func (r *Repository) getTunnelEntryPortRanges(tunnelIDs []int64) map[int64]tunnelPortRange { result := make(map[int64]tunnelPortRange) if len(tunnelIDs) == 0 { return result } type entryNode struct { TunnelID int64 NodeID int64 } var entries []entryNode r.db.Model(&model.ChainTunnel{}). Select("tunnel_id, node_id"). Where("tunnel_id IN (?) AND chain_type = ?", tunnelIDs, "1"). Find(&entries) nodeIDs := make([]int64, 0, len(entries)) nodeSet := make(map[int64]struct{}) for _, e := range entries { if _, exists := nodeSet[e.NodeID]; !exists { nodeSet[e.NodeID] = struct{}{} nodeIDs = append(nodeIDs, e.NodeID) } } type nodePort struct { ID int64 Port string } var nodePorts []nodePort if len(nodeIDs) > 0 { r.db.Model(&model.Node{}).Select("id, port").Where("id IN (?)", nodeIDs).Find(&nodePorts) } nodePortMap := make(map[int64]string) for _, np := range nodePorts { nodePortMap[np.ID] = np.Port } for _, e := range entries { portSpec := nodePortMap[e.NodeID] if portSpec == "" { continue } minP, maxP := parsePortRangeMinMax(portSpec) if minP <= 0 || maxP <= 0 { continue } pr, exists := result[e.TunnelID] if !exists { result[e.TunnelID] = tunnelPortRange{min: minP, max: maxP} } else { if minP < pr.min { pr.min = minP } if maxP > pr.max { pr.max = maxP } result[e.TunnelID] = pr } } return result } func parsePortRangeMinMax(input string) (int, int) { input = strings.TrimSpace(input) if input == "" { return 0, 0 } minPort, maxPort := 0, 0 parts := strings.Split(input, ",") for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } if strings.Contains(part, "-") { r := strings.SplitN(part, "-", 2) if len(r) != 2 { continue } start, end := parseIntPort(r[0]), parseIntPort(r[1]) if start <= 0 || end <= 0 { continue } if end < start { start, end = end, start } if minPort == 0 || start < minPort { minPort = start } if maxPort == 0 || end > maxPort { maxPort = end } continue } p := parseIntPort(part) if p <= 0 { continue } if minPort == 0 || p < minPort { minPort = p } if maxPort == 0 || p > maxPort { maxPort = p } } return minPort, maxPort } func parseIntPort(s string) int { var p int fmt.Sscanf(strings.TrimSpace(s), "%d", &p) return p } func (r *Repository) ListTunnels() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var tunnels []model.Tunnel if err := r.db.Order("inx ASC, id ASC").Find(&tunnels).Error; err != nil { return nil, err } tunnelMap := make(map[int64]map[string]interface{}) orderedIDs := make([]int64, 0, len(tunnels)) for _, t := range tunnels { tunnelMap[t.ID] = map[string]interface{}{ "id": t.ID, "inx": t.Inx, "name": t.Name, "type": t.Type, "flow": t.Flow, "trafficRatio": t.TrafficRatio, "status": t.Status, "createdTime": t.CreatedTime, "inIp": nullableString(t.InIP), "ipPreference": t.IPPreference, "probeTargetHost": t.ProbeTargetHost, "probeTargetPort": t.ProbeTargetPort, "inNodeId": make([]map[string]interface{}, 0), "outNodeId": make([]map[string]interface{}, 0), "chainNodes": make([][]map[string]interface{}, 0), } orderedIDs = append(orderedIDs, t.ID) } // Build node IP map nodeIPMap := map[int64]string{} var nodeList []model.Node if err := r.db.Select("id, server_ip").Find(&nodeList).Error; err == nil { for _, n := range nodeList { nodeIPMap[n.ID] = n.ServerIP } } // Load chain tunnels var chains []model.ChainTunnel if err := r.db.Order("tunnel_id ASC, chain_type ASC, inx ASC, id ASC").Find(&chains).Error; err != nil { return nil, err } chainBucket := map[int64]map[int][]map[string]interface{}{} inNodeIPs := map[int64][]string{} for _, c := range chains { t, ok := tunnelMap[c.TunnelID] if !ok { continue } chainTypeInt := 0 fmt.Sscanf(c.ChainType, "%d", &chainTypeInt) inx := int64(0) if c.Inx.Valid { inx = c.Inx.Int64 } nodeObj := map[string]interface{}{ "nodeId": c.NodeID, "chainType": chainTypeInt, "inx": inx, } if c.Protocol.Valid { nodeObj["protocol"] = c.Protocol.String } if c.Strategy.Valid { nodeObj["strategy"] = c.Strategy.String } if c.ConnectIP.Valid { nodeObj["connectIp"] = c.ConnectIP.String } switch chainTypeInt { case 1: t["inNodeId"] = append(t["inNodeId"].([]map[string]interface{}), nodeObj) if ip, ok := nodeIPMap[c.NodeID]; ok && ip != "" { inNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip) } case 2: if _, ok := chainBucket[c.TunnelID]; !ok { chainBucket[c.TunnelID] = map[int][]map[string]interface{}{} } chainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj) case 3: t["outNodeId"] = append(t["outNodeId"].([]map[string]interface{}), nodeObj) } } for tunnelID, groups := range chainBucket { t := tunnelMap[tunnelID] if t == nil { continue } keys := make([]int, 0, len(groups)) for k := range groups { keys = append(keys, k) } sort.Ints(keys) ordered := make([][]map[string]interface{}, 0, len(keys)) for _, k := range keys { ordered = append(ordered, groups[k]) } t["chainNodes"] = ordered if s, ok := t["inIp"].(string); !ok || strings.TrimSpace(s) == "" { if ips := inNodeIPs[tunnelID]; len(ips) > 0 { t["inIp"] = strings.Join(ips, ",") } } } result := make([]map[string]interface{}, 0, len(orderedIDs)) for _, id := range orderedIDs { if t, ok := tunnelMap[id]; ok { result = append(result, t) } } return result, nil } // ─── Group Queries ─────────────────────────────────────────────────── func (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var groups []model.TunnelGroup if err := r.db.Order("id ASC").Find(&groups).Error; err != nil { return nil, err } result := make([]map[string]interface{}, 0, len(groups)) for _, g := range groups { ids, names, err := r.listTunnelGroupMembers(g.ID) if err != nil { return nil, err } result = append(result, map[string]interface{}{ "id": g.ID, "name": g.Name, "status": g.Status, "tunnelIds": ids, "tunnelNames": names, "createdTime": g.CreatedTime, }) } return result, nil } func (r *Repository) ListUserGroups() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var groups []model.UserGroup if err := r.db.Order("id ASC").Find(&groups).Error; err != nil { return nil, err } result := make([]map[string]interface{}, 0, len(groups)) for _, g := range groups { ids, names, err := r.listUserGroupMembers(g.ID) if err != nil { return nil, err } result = append(result, map[string]interface{}{ "id": g.ID, "name": g.Name, "status": g.Status, "userIds": ids, "userNames": names, "createdTime": g.CreatedTime, }) } return result, nil } func (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } type permRow struct { ID int64 UserGroupID int64 UserGroupName sql.NullString TunnelGroupID int64 TunnelGroupName sql.NullString CreatedTime int64 } var rows []permRow err := r.db.Model(&model.GroupPermission{}). Select("group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time"). Joins("LEFT JOIN user_group ON user_group.id = group_permission.user_group_id"). Joins("LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id"). Order("group_permission.id ASC"). Find(&rows).Error if err != nil { return nil, err } result := make([]map[string]interface{}, 0, len(rows)) for _, r := range rows { result = append(result, map[string]interface{}{ "id": r.ID, "userGroupId": r.UserGroupID, "userGroupName": nullableString(r.UserGroupName), "tunnelGroupId": r.TunnelGroupID, "tunnelGroupName": nullableString(r.TunnelGroupName), "createdTime": r.CreatedTime, }) } return result, nil } func (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) { type row struct { ID int64 Name string } var rows []row err := r.db.Model(&model.TunnelGroupTunnel{}). Select("tunnel.id, tunnel.name"). Joins("JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id"). Where("tunnel_group_tunnel.tunnel_group_id = ?", groupID). Order("tunnel.id ASC"). Find(&rows).Error if err != nil { return nil, nil, err } ids := make([]int64, 0, len(rows)) names := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) names = append(names, r.Name) } return ids, names, nil } func (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) { type row struct { ID int64 Name string } var rows []row err := r.db.Model(&model.UserGroupUser{}). Select(`"user".id, "user"."user" AS name`). Joins(`JOIN "user" ON "user".id = user_group_user.user_id`). Where("user_group_user.user_group_id = ?", groupID). Order(`"user".id ASC`). Find(&rows).Error if err != nil { return nil, nil, err } ids := make([]int64, 0, len(rows)) names := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) names = append(names, r.Name) } return ids, names, nil } // ─── PeerShare CRUD ────────────────────────────────────────────────── func (r *Repository) CreatePeerShare(share *model.PeerShare) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Create(share).Error } func (r *Repository) UpdatePeerShare(share *model.PeerShare) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.PeerShare{}).Where("id = ?", share.ID).Updates(map[string]interface{}{ "name": share.Name, "max_bandwidth": share.MaxBandwidth, "expiry_time": share.ExpiryTime, "port_range_start": share.PortRangeStart, "port_range_end": share.PortRangeEnd, "is_active": share.IsActive, "updated_time": share.UpdatedTime, "allowed_domains": share.AllowedDomains, "allowed_ips": share.AllowedIPs, }).Error } func (r *Repository) DeletePeerShare(id int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Transaction(func(tx *gorm.DB) error { tx.Where("share_id = ?", id).Delete(&model.PeerShareRuntime{}) return tx.Where("id = ?", id).Delete(&model.PeerShare{}).Error }) } func (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var s model.PeerShare err := r.db.Where("id = ?", id).First(&s).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &s, nil } func (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var s model.PeerShare err := r.db.Where("token = ?", token).First(&s).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &s, nil } func (r *Repository) ListPeerShares() ([]model.PeerShare, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var shares []model.PeerShare err := r.db.Order("id DESC").Find(&shares).Error return shares, err } func (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } if shareID <= 0 || delta <= 0 { return nil } return r.db.Model(&model.PeerShare{}).Where("id = ?", shareID). UpdateColumns(map[string]interface{}{ "current_flow": gorm.Expr("current_flow + ?", delta), "updated_time": unixMilliNow(), }).Error } func (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } if shareID <= 0 { return nil } if updatedTime <= 0 { updatedTime = unixMilliNow() } return r.db.Model(&model.PeerShare{}).Where("id = ?", shareID).Updates(map[string]interface{}{ "current_flow": 0, "updated_time": updatedTime, }).Error } // ─── PeerShareRuntime CRUD ─────────────────────────────────────────── func (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } if item == nil { return errors.New("runtime item is nil") } return r.db.Create(item).Error } func (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } if item == nil { return errors.New("runtime item is nil") } return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", item.ID).Updates(map[string]interface{}{ "binding_id": item.BindingID, "role": item.Role, "chain_name": item.ChainName, "service_name": item.ServiceName, "protocol": item.Protocol, "strategy": item.Strategy, "port": item.Port, "target": item.Target, "applied": item.Applied, "status": item.Status, "updated_time": item.UpdatedTime, }).Error } func (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{ "status": 0, "updated_time": updatedTime, }).Error } func (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var item model.PeerShareRuntime err := r.db.Where("share_id = ? AND resource_key = ?", shareID, resourceKey).First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var item model.PeerShareRuntime err := r.db.Where("share_id = ? AND reservation_id = ?", shareID, reservationID).First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var item model.PeerShareRuntime err := r.db.Where("share_id = ? AND binding_id = ?", shareID, bindingID).First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var item model.PeerShareRuntime err := r.db.Where("id = ?", id).First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var out []model.PeerShareRuntime err := r.db.Where("share_id = ? AND status = 1", shareID).Order("port ASC, id ASC").Find(&out).Error if err != nil { return nil, err } if out == nil { out = make([]model.PeerShareRuntime, 0) } return out, nil } func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var ports []int err := r.db.Model(&model.PeerShareRuntime{}). Where("share_id = ? AND node_id = ? AND status = 1 AND port > 0", shareID, nodeID). Pluck("port", &ports).Error if err != nil { return nil, err } if ports == nil { ports = make([]int, 0) } return ports, nil } func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var items []model.PeerShareRuntime err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward"). Order("id ASC"). Find(&items).Error if err != nil { return nil, err } if items == nil { items = make([]model.PeerShareRuntime, 0) } return items, nil } func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } serviceName = strings.TrimSpace(serviceName) if serviceName == "" { return []model.PeerShareRuntime{}, nil } var items []model.PeerShareRuntime err := r.db.Where("node_id = ? AND service_name = ? AND status = 1 AND role = ?", nodeID, serviceName, "forward"). Order("id ASC"). Find(&items).Error if err != nil { return nil, err } if items == nil { items = make([]model.PeerShareRuntime, 0) } return items, nil } func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var names []string err := r.db.Model(&model.PeerShareRuntime{}). Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward"). Pluck("service_name", &names).Error if err != nil { return nil, err } if names == nil { names = make([]string, 0) } return names, nil } func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) { if r == nil || r.db == nil { return false, errors.New("repository not initialized") } var count int64 err := r.db.Model(&model.PeerShareRuntime{}). Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime). Count(&count).Error if err != nil { return false, err } return count > 0, nil } func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var item model.PeerShareRuntime err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } serviceName = strings.TrimSpace(serviceName) if shareID <= 0 || serviceName == "" { return nil, nil } var item model.PeerShareRuntime err := r.db.Where("share_id = ? AND service_name = ? AND status = 1 AND role = ?", shareID, serviceName, "forward"). Order("id ASC"). First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) { if r == nil || r.db == nil { return false, errors.New("repository not initialized") } var count int64 err := r.db.Model(&model.PeerShareRuntime{}). Where("node_id = ? AND port = ? AND status = 1", nodeID, port). Count(&count).Error if err != nil { return false, err } return count > 0, nil } func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{ "service_name": serviceName, "applied": 1, "updated_time": updatedTime, }).Error } func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } if shareID <= 0 || port <= 0 { return nil } if updatedTime <= 0 { updatedTime = unixMilliNow() } return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{ "status": 0, "applied": 0, "service_name": "", "updated_time": updatedTime, }).Error } func (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } serviceName = strings.TrimSpace(serviceName) if shareID <= 0 || serviceName == "" { return nil } if updatedTime <= 0 { updatedTime = unixMilliNow() } return r.db.Model(&model.PeerShareRuntime{}). Where("share_id = ? AND status = 1 AND role = ? AND service_name = ?", shareID, "forward", serviceName). Updates(map[string]interface{}{ "status": 0, "applied": 0, "service_name": "", "updated_time": updatedTime, }).Error } // ─── FederationTunnelBinding ───────────────────────────────────────── func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } if item == nil { return errors.New("binding item is nil") } return r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{ {Name: "tunnel_id"}, {Name: "node_id"}, {Name: "chain_type"}, {Name: "hop_inx"}, }, DoUpdates: clause.AssignmentColumns([]string{ "remote_url", "resource_key", "remote_binding_id", "allocated_port", "status", "updated_time", }), }).Create(item).Error } func (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var out []model.FederationTunnelBinding err := r.db.Where("tunnel_id = ? AND status = 1", tunnelID). Order("chain_type ASC, hop_inx ASC, id ASC").Find(&out).Error if err != nil { return nil, err } if out == nil { out = make([]model.FederationTunnelBinding, 0) } return out, nil } func (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Where("tunnel_id = ?", tunnelID).Delete(&model.FederationTunnelBinding{}).Error } // ─── Export Methods ────────────────────────────────────────────────── func (r *Repository) ExportAll() (*model.BackupData, error) { backup := &model.BackupData{Version: "1.0", ExportedAt: unixMilliNow()} users, err := r.exportUsers() if err != nil { return nil, fmt.Errorf("export users failed: %w", err) } backup.Users = users nodes, err := r.exportNodes() if err != nil { return nil, fmt.Errorf("export nodes failed: %w", err) } backup.Nodes = nodes tunnels, err := r.exportTunnels() if err != nil { return nil, fmt.Errorf("export tunnels failed: %w", err) } backup.Tunnels = tunnels forwards, err := r.exportForwards() if err != nil { return nil, fmt.Errorf("export forwards failed: %w", err) } backup.Forwards = forwards userTunnels, err := r.exportUserTunnels() if err != nil { return nil, fmt.Errorf("export user tunnels failed: %w", err) } backup.UserTunnels = userTunnels speedLimits, err := r.exportSpeedLimits() if err != nil { return nil, fmt.Errorf("export speed limits failed: %w", err) } backup.SpeedLimits = speedLimits tunnelGroups, err := r.exportTunnelGroups() if err != nil { return nil, fmt.Errorf("export tunnel groups failed: %w", err) } backup.TunnelGroups = tunnelGroups userGroups, err := r.exportUserGroups() if err != nil { return nil, fmt.Errorf("export user groups failed: %w", err) } backup.UserGroups = userGroups permissions, err := r.exportPermissions() if err != nil { return nil, fmt.Errorf("export permissions failed: %w", err) } backup.Permissions = permissions configs, err := r.ListConfigs() if err != nil { return nil, fmt.Errorf("export configs failed: %w", err) } backup.Configs = FilterSensitiveConfigs(configs) return backup, nil } func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) { backup := &model.BackupData{Version: "1.0", ExportedAt: unixMilliNow()} typeSet := make(map[string]bool) for _, t := range types { typeSet[t] = true } if typeSet["users"] { v, err := r.exportUsers() if err != nil { return nil, fmt.Errorf("export users failed: %w", err) } backup.Users = v } if typeSet["nodes"] { v, err := r.exportNodes() if err != nil { return nil, fmt.Errorf("export nodes failed: %w", err) } backup.Nodes = v } if typeSet["tunnels"] { v, err := r.exportTunnels() if err != nil { return nil, fmt.Errorf("export tunnels failed: %w", err) } backup.Tunnels = v } if typeSet["forwards"] { v, err := r.exportForwards() if err != nil { return nil, fmt.Errorf("export forwards failed: %w", err) } backup.Forwards = v } if typeSet["userTunnels"] { v, err := r.exportUserTunnels() if err != nil { return nil, fmt.Errorf("export user tunnels failed: %w", err) } backup.UserTunnels = v } if typeSet["speedLimits"] { v, err := r.exportSpeedLimits() if err != nil { return nil, fmt.Errorf("export speed limits failed: %w", err) } backup.SpeedLimits = v } if typeSet["tunnelGroups"] { v, err := r.exportTunnelGroups() if err != nil { return nil, fmt.Errorf("export tunnel groups failed: %w", err) } backup.TunnelGroups = v } if typeSet["userGroups"] { v, err := r.exportUserGroups() if err != nil { return nil, fmt.Errorf("export user groups failed: %w", err) } backup.UserGroups = v } if typeSet["permissions"] { v, err := r.exportPermissions() if err != nil { return nil, fmt.Errorf("export permissions failed: %w", err) } backup.Permissions = v } if typeSet["configs"] { v, err := r.ListConfigs() if err != nil { return nil, fmt.Errorf("export configs failed: %w", err) } backup.Configs = FilterSensitiveConfigs(v) } return backup, nil } func (r *Repository) exportUsers() ([]model.UserBackup, error) { var users []model.User if err := r.db.Order("id ASC").Find(&users).Error; err != nil { return nil, err } userIDs := make([]int64, 0, len(users)) for _, u := range users { userIDs = append(userIDs, u.ID) } quotaMap, err := r.ListUserQuotaViewsByUserIDs(userIDs, time.Now()) if err != nil { return nil, err } out := make([]model.UserBackup, 0, len(users)) for _, u := range users { b := model.UserBackup{ ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID, ExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow, FlowResetTime: u.FlowResetTime, Num: u.Num, CreatedTime: u.CreatedTime, Status: u.Status, } if quota := quotaMap[u.ID]; quota != nil { b.DailyQuotaGB = quota.DailyLimitGB b.MonthlyQuotaGB = quota.MonthlyLimitGB b.DisabledByQuota = quota.DisabledByQuota b.QuotaDisabledAt = quota.DisabledAt } if u.UpdatedTime.Valid { b.UpdatedTime = u.UpdatedTime.Int64 } out = append(out, b) } return out, nil } func (r *Repository) exportNodes() ([]model.NodeBackup, error) { var nodes []model.Node if err := r.db.Order("inx ASC, id ASC").Find(&nodes).Error; err != nil { return nil, err } out := make([]model.NodeBackup, 0, len(nodes)) for _, n := range nodes { b := model.NodeBackup{ ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP, Remark: n.Remark.String, RenewalCycle: n.RenewalCycle.String, Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks, CreatedTime: n.CreatedTime, Status: n.Status, TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr, Inx: n.Inx, IsRemote: n.IsRemote, } if n.ExpiryTime.Valid { b.ExpiryTime = n.ExpiryTime.Int64 } if n.UpdatedTime.Valid { b.UpdatedTime = n.UpdatedTime.Int64 } if n.ServerIPV4.Valid { b.ServerIPv4 = n.ServerIPV4.String } if n.ServerIPV6.Valid { b.ServerIPv6 = n.ServerIPV6.String } if n.InterfaceName.Valid { b.InterfaceName = n.InterfaceName.String } if n.Version.Valid { b.Version = n.Version.String } if n.RemoteURL.Valid { b.RemoteURL = n.RemoteURL.String } if n.RemoteToken.Valid { b.RemoteToken = n.RemoteToken.String } if n.RemoteConfig.Valid { b.RemoteConfig = n.RemoteConfig.String } out = append(out, b) } return out, nil } func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) { var tunnels []model.Tunnel if err := r.db.Order("inx ASC, id ASC").Find(&tunnels).Error; err != nil { return nil, err } out := make([]model.TunnelBackup, 0, len(tunnels)) for _, t := range tunnels { b := model.TunnelBackup{ ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio, Type: t.Type, Protocol: t.Protocol, Flow: t.Flow, CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime, Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference, ProbeTargetHost: t.ProbeTargetHost, ProbeTargetPort: t.ProbeTargetPort, } if t.InIP.Valid { b.InIP = t.InIP.String } chains, err := r.exportChainTunnels(t.ID) if err != nil { return nil, err } b.ChainTunnels = chains out = append(out, b) } return out, nil } func (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) { var chains []model.ChainTunnel if err := r.db.Where("tunnel_id = ?", tunnelID).Order("inx ASC, id ASC").Find(&chains).Error; err != nil { return nil, err } out := make([]model.ChainTunnelBackup, 0, len(chains)) for _, c := range chains { b := model.ChainTunnelBackup{ ID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID, } if c.Port.Valid { b.Port = int(c.Port.Int64) } if c.Strategy.Valid { b.Strategy = c.Strategy.String } if c.Inx.Valid { b.Inx = int(c.Inx.Int64) } if c.Protocol.Valid { b.Protocol = c.Protocol.String } out = append(out, b) } return out, nil } func (r *Repository) exportForwards() ([]model.ForwardBackup, error) { var forwards []model.Forward if err := r.db.Order("id ASC").Find(&forwards).Error; err != nil { return nil, err } out := make([]model.ForwardBackup, 0, len(forwards)) for _, f := range forwards { b := model.ForwardBackup{ ID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name, TunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy, InFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime, UpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx, IPMaxConn: f.IPMaxConn, ProxyProtocol: f.ProxyProtocol, ProxyProtocolReceive: f.ProxyProtocolReceive, ProxyProtocolSend: f.ProxyProtocolSend, } if f.SpeedID.Valid { v := f.SpeedID.Int64 b.SpeedID = &v } if f.IPSpeedID.Valid { v := f.IPSpeedID.Int64 b.IPSpeedID = &v } ports, err := r.exportForwardPorts(f.ID) if err != nil { return nil, err } portsCopy := append([]model.ForwardPortBackup(nil), ports...) b.ForwardPorts = &portsCopy out = append(out, b) } return out, nil } func (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) { var fps []model.ForwardPort if err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&fps).Error; err != nil { return nil, err } out := make([]model.ForwardPortBackup, 0, len(fps)) for _, fp := range fps { out = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port}) } return out, nil } func (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) { var uts []model.UserTunnel if err := r.db.Order("id ASC").Find(&uts).Error; err != nil { return nil, err } out := make([]model.UserTunnelBackup, 0, len(uts)) for _, ut := range uts { b := model.UserTunnelBackup{ ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID, Num: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow, FlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status, } if ut.SpeedID.Valid { b.SpeedID = ut.SpeedID.Int64 } out = append(out, b) } return out, nil } func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) { var sls []model.SpeedLimit if err := r.db.Order("id ASC").Find(&sls).Error; err != nil { return nil, err } out := make([]model.SpeedLimitBackup, 0, len(sls)) for _, sl := range sls { b := model.SpeedLimitBackup{ ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed), CreatedTime: sl.CreatedTime, Status: sl.Status, } if sl.UpdatedTime.Valid { b.UpdatedTime = sl.UpdatedTime.Int64 } out = append(out, b) } return out, nil } func (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) { var groups []model.TunnelGroup if err := r.db.Order("id ASC").Find(&groups).Error; err != nil { return nil, err } out := make([]model.TunnelGroupBackup, 0, len(groups)) for _, tg := range groups { b := model.TunnelGroupBackup{ ID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime, UpdatedTime: tg.UpdatedTime, Status: tg.Status, } var tunnelIDs []int64 r.db.Model(&model.TunnelGroupTunnel{}).Where("tunnel_group_id = ?", tg.ID).Pluck("tunnel_id", &tunnelIDs) b.Tunnels = tunnelIDs out = append(out, b) } return out, nil } func (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) { var groups []model.UserGroup if err := r.db.Order("id ASC").Find(&groups).Error; err != nil { return nil, err } out := make([]model.UserGroupBackup, 0, len(groups)) for _, ug := range groups { b := model.UserGroupBackup{ ID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime, UpdatedTime: ug.UpdatedTime, Status: ug.Status, } var userIDs []int64 r.db.Model(&model.UserGroupUser{}).Where("user_group_id = ?", ug.ID).Pluck("user_id", &userIDs) b.Users = userIDs out = append(out, b) } return out, nil } func (r *Repository) exportPermissions() ([]model.PermissionBackup, error) { var perms []model.GroupPermission if err := r.db.Order("id ASC").Find(&perms).Error; err != nil { return nil, err } out := make([]model.PermissionBackup, 0, len(perms)) for _, p := range perms { b := model.PermissionBackup{ ID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID, CreatedTime: p.CreatedTime, } var grants []model.GroupPermissionGrant r.db.Where("user_group_id = ? AND tunnel_group_id = ?", p.UserGroupID, p.TunnelGroupID).Find(&grants) for _, g := range grants { b.Grants = append(b.Grants, model.PermissionGrantBackup{ ID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID, UserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime, CreatedByGroup: g.CreatedByGroup, }) } out = append(out, b) } return out, nil } // ─── Import Methods ────────────────────────────────────────────────── func (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) { result := &model.ImportResult{} typeSet := make(map[string]bool) for _, t := range types { typeSet[t] = true } err := r.db.Transaction(func(tx *gorm.DB) error { now := unixMilliNow() if typeSet["users"] && len(backup.Users) > 0 { count, err := importUsers(tx, backup.Users, now) if err != nil { return fmt.Errorf("import users failed: %w", err) } result.UsersImported = count } if typeSet["nodes"] && len(backup.Nodes) > 0 { count, err := importNodes(tx, backup.Nodes, now) if err != nil { return fmt.Errorf("import nodes failed: %w", err) } result.NodesImported = count } if typeSet["tunnels"] && len(backup.Tunnels) > 0 { count, err := importTunnels(tx, backup.Tunnels, now) if err != nil { return fmt.Errorf("import tunnels failed: %w", err) } result.TunnelsImported = count } if typeSet["forwards"] && len(backup.Forwards) > 0 { count, err := importForwards(tx, backup.Forwards, now) if err != nil { return fmt.Errorf("import forwards failed: %w", err) } result.ForwardsImported = count } if typeSet["userTunnels"] && len(backup.UserTunnels) > 0 { count, err := importUserTunnels(tx, backup.UserTunnels, now) if err != nil { return fmt.Errorf("import user tunnels failed: %w", err) } result.UserTunnelsImported = count } if typeSet["speedLimits"] && len(backup.SpeedLimits) > 0 { count, err := importSpeedLimits(tx, backup.SpeedLimits, now) if err != nil { return fmt.Errorf("import speed limits failed: %w", err) } result.SpeedLimitsImported = count } if typeSet["tunnelGroups"] && len(backup.TunnelGroups) > 0 { count, err := importTunnelGroups(tx, backup.TunnelGroups, now) if err != nil { return fmt.Errorf("import tunnel groups failed: %w", err) } result.TunnelGroupsImported = count } if typeSet["userGroups"] && len(backup.UserGroups) > 0 { count, err := importUserGroups(tx, backup.UserGroups, now) if err != nil { return fmt.Errorf("import user groups failed: %w", err) } result.UserGroupsImported = count } if typeSet["permissions"] && len(backup.Permissions) > 0 { count, err := importPermissions(tx, backup.Permissions, now) if err != nil { return fmt.Errorf("import permissions failed: %w", err) } result.PermissionsImported = count } if typeSet["configs"] && len(backup.Configs) > 0 { count, err := importConfigs(tx, backup.Configs, now) if err != nil { return fmt.Errorf("import configs failed: %w", err) } result.ConfigsImported = count } return nil }) if err != nil { return nil, err } return result, nil } func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) { count := 0 for _, u := range users { pwdHash, status, err := normalizeImportedUserPassword(u.Pwd, u.Status) if err != nil { return count, err } item := model.User{ ID: u.ID, User: u.User, Pwd: pwdHash, RoleID: u.RoleID, ExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow, FlowResetTime: u.FlowResetTime, Num: u.Num, CreatedTime: u.CreatedTime, UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, Status: status, PasswordChangedAt: now, } err = tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow", "flow_reset_time", "num", "updated_time", "status", "password_changed_at", }), }).Create(&item).Error if err != nil { return count, err } if u.DailyQuotaGB > 0 || u.MonthlyQuotaGB > 0 || u.DisabledByQuota != 0 || u.QuotaDisabledAt > 0 { current := time.UnixMilli(now) dayKey := int64(current.Year()*10000 + int(current.Month())*100 + current.Day()) monthKey := int64(current.Year()*100 + int(current.Month())) quotaItem := model.UserQuota{ UserID: u.ID, DailyLimitGB: u.DailyQuotaGB, MonthlyLimitGB: u.MonthlyQuotaGB, DailyUsedBytes: 0, MonthlyUsedBytes: 0, DayKey: dayKey, MonthKey: monthKey, DisabledByQuota: u.DisabledByQuota, DisabledAt: u.QuotaDisabledAt, PausedForwardIDs: "", CreatedTime: now, UpdatedTime: now, } err = tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{ "daily_limit_gb", "monthly_limit_gb", "daily_used_bytes", "monthly_used_bytes", "day_key", "month_key", "disabled_by_quota", "disabled_at", "paused_forward_ids", "updated_time", }), }).Create("aItem).Error if err != nil { return count, err } } else { if err := tx.Where("user_id = ?", u.ID).Delete(&model.UserQuota{}).Error; err != nil { return count, err } } count++ } return count, nil } func normalizeImportedUserPassword(password string, status int) (string, int, error) { password = strings.TrimSpace(password) if strings.HasPrefix(password, "$2") { return password, status, nil } if security.IsLegacyPasswordHash(password) || password == "" { replacement, err := randomPasswordHash() if err != nil { return "", status, err } return replacement, 0, nil } hash, err := security.HashPassword(password) if err != nil { return "", status, err } return hash, status, nil } func randomPasswordHash() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return security.HashPassword(hex.EncodeToString(buf)) } func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) { count := 0 for _, n := range nodes { item := model.Node{ ID: n.ID, Name: n.Name, Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""}, ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0}, RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""}, Secret: n.Secret, ServerIP: n.ServerIP, ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true}, ServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true}, Port: n.Port, InterfaceName: sql.NullString{String: n.InterfaceName, Valid: true}, Version: sql.NullString{String: n.Version, Valid: true}, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks, CreatedTime: n.CreatedTime, UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, Status: n.Status, TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr, Inx: n.Inx, IsRemote: n.IsRemote, RemoteURL: sql.NullString{String: n.RemoteURL, Valid: true}, RemoteToken: sql.NullString{String: n.RemoteToken, Valid: true}, RemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true}, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "name", "remark", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", "http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr", "inx", "is_remote", "remote_url", "remote_token", "remote_config", }), }).Create(&item).Error if err != nil { return count, err } count++ } return count, nil } func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) { count := 0 for _, t := range tunnels { item := model.Tunnel{ ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio, Type: t.Type, Protocol: t.Protocol, Flow: t.Flow, CreatedTime: t.CreatedTime, UpdatedTime: now, Status: t.Status, InIP: sql.NullString{String: t.InIP, Valid: true}, Inx: t.Inx, IPPreference: t.IPPreference, ProbeTargetHost: t.ProbeTargetHost, ProbeTargetPort: t.ProbeTargetPort, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "name", "traffic_ratio", "type", "protocol", "flow", "updated_time", "status", "in_ip", "inx", "ip_preference", "probe_target_host", "probe_target_port", }), }).Create(&item).Error if err != nil { return count, err } for _, ct := range t.ChainTunnels { chainItem := model.ChainTunnel{ ID: ct.ID, TunnelID: ct.TunnelID, ChainType: ct.ChainType, NodeID: ct.NodeID, Port: sql.NullInt64{Int64: int64(ct.Port), Valid: true}, Strategy: sql.NullString{String: ct.Strategy, Valid: true}, Inx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true}, Protocol: sql.NullString{String: ct.Protocol, Valid: true}, } err = tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "chain_type", "node_id", "port", "strategy", "inx", "protocol", }), }).Create(&chainItem).Error if err != nil { return count, err } } count++ } return count, nil } func nullableBackupInt64(v *int64) int64 { if v == nil { return 0 } return *v } func importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) { count := 0 for _, f := range forwards { item := model.Forward{ ID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name, TunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy, InFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime, UpdatedTime: now, Status: f.Status, Inx: f.Inx, SpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.SpeedID), Valid: f.SpeedID != nil && *f.SpeedID > 0}, IPMaxConn: f.IPMaxConn, IPSpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.IPSpeedID), Valid: f.IPSpeedID != nil && *f.IPSpeedID > 0}, ProxyProtocol: f.ProxyProtocol, ProxyProtocolReceive: f.ProxyProtocolReceive, ProxyProtocolSend: f.ProxyProtocolSend, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "user_id", "user_name", "name", "tunnel_id", "remote_addr", "strategy", "in_flow", "out_flow", "updated_time", "status", "inx", "speed_id", "ip_max_conn", "ip_speed_id", "proxy_protocol", "proxy_protocol_receive", "proxy_protocol_send", }), }).Create(&item).Error if err != nil { return count, err } if f.ForwardPorts != nil { if err := tx.Where("forward_id = ?", f.ID).Delete(&model.ForwardPort{}).Error; err != nil { return count, err } for _, fp := range *f.ForwardPorts { if err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil { return count, err } } } count++ } return count, nil } func importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) { count := 0 for _, ut := range userTunnels { item := model.UserTunnel{ ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID, SpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0}, Num: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow, FlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "user_id", "tunnel_id", "speed_id", "num", "flow", "in_flow", "out_flow", "flow_reset_time", "exp_time", "status", }), }).Create(&item).Error if err != nil { return count, err } count++ } return count, nil } func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) { count := 0 for _, sl := range speedLimits { item := model.SpeedLimit{ ID: sl.ID, Name: sl.Name, Speed: int(sl.Speed), TunnelID: sql.NullInt64{Int64: 0, Valid: false}, TunnelName: sql.NullString{String: "", Valid: false}, CreatedTime: sl.CreatedTime, UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, Status: sl.Status, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ "name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status", }), }).Create(&item).Error if err != nil { return count, err } count++ } return count, nil } func importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) { count := 0 for _, tg := range tunnelGroups { item := model.TunnelGroup{ ID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime, UpdatedTime: now, Status: tg.Status, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "updated_time", "status"}), }).Create(&item).Error if err != nil { return count, err } if err := tx.Where("tunnel_group_id = ?", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil { return count, err } for _, tunnelID := range tg.Tunnels { if err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil { return count, err } } count++ } return count, nil } func importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) { count := 0 for _, ug := range userGroups { item := model.UserGroup{ ID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime, UpdatedTime: now, Status: ug.Status, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "updated_time", "status"}), }).Create(&item).Error if err != nil { return count, err } if err := tx.Where("user_group_id = ?", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil { return count, err } for _, userID := range ug.Users { if err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil { return count, err } } count++ } return count, nil } func importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) { count := 0 for _, p := range permissions { item := model.GroupPermission{ ID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID, CreatedTime: p.CreatedTime, } err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"user_group_id", "tunnel_group_id"}), }).Create(&item).Error if err != nil { return count, err } for _, g := range p.Grants { grantItem := model.GroupPermissionGrant{ ID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID, UserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime, CreatedByGroup: g.CreatedByGroup, } err = tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"user_tunnel_id", "created_by_group"}), }).Create(&grantItem).Error if err != nil { return count, err } } count++ } return count, nil } func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) { configs = FilterSensitiveConfigs(configs) count := 0 for name, value := range configs { err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "name"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "time"}), }).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error if err != nil { return count, err } count++ } return count, nil } // ─── Jobs Queries (background stats / expiry) ─────────────────────── func (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Where("created_time < ?", cutoffMs).Delete(&model.StatisticsFlow{}).Error } func (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var users []model.User err := r.db.Order("id ASC").Find(&users).Error if err != nil { return nil, err } out := make([]model.UserFlowSnapshot, len(users)) for i, u := range users { out[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow} } return out, nil } func (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) { if r == nil || r.db == nil { return sql.NullInt64{}, errors.New("repository not initialized") } var sf model.StatisticsFlow err := r.db.Where("user_id = ?", userID).Order("id DESC").First(&sf).Error if errors.Is(err, gorm.ErrRecordNotFound) { return sql.NullInt64{}, nil } if err != nil { return sql.NullInt64{}, err } return sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil } func (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Create(&model.StatisticsFlow{ UserID: userID, Flow: flow, TotalFlow: totalFlow, Time: timeText, CreatedTime: createdTime, }).Error } func (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } updates := map[string]interface{}{"in_flow": 0, "out_flow": 0} if day == lastDay { return r.db.Model(&model.User{}). Where("flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)", day, lastDay). Updates(updates).Error } return r.db.Model(&model.User{}). Where("flow_reset_time != 0 AND flow_reset_time = ?", day). Updates(updates).Error } func (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } updates := map[string]interface{}{"in_flow": 0, "out_flow": 0} if day == lastDay { return r.db.Model(&model.UserTunnel{}). Where("flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)", day, lastDay). Updates(updates).Error } return r.db.Model(&model.UserTunnel{}). Where("flow_reset_time != 0 AND flow_reset_time = ?", day). Updates(updates).Error } func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var ids []int64 err := r.db.Model(&model.User{}). Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs). Pluck("id", &ids).Error if err != nil { return nil, err } return ids, nil } func (r *Repository) DisableUser(userID int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.User{}).Where("id = ?", userID).Update("status", 0).Error } func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var uts []model.UserTunnel err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error if err != nil { return nil, err } out := make([]model.ExpiredUserTunnel, len(uts)) for i, ut := range uts { out[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID} } return out, nil } func (r *Repository) DisableUserTunnel(id int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } return r.db.Model(&model.UserTunnel{}).Where("id = ?", id).Update("status", 0).Error } func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") } var ut model.UserTunnel err := r.db.Where("id = ?", id).First(&ut).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &ut, nil } // ─── Migration ─────────────────────────────────────────────────────── const currentSchemaVersion = 6 var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns var migrateTunnelMetricBucketUniqueIndexFn = migrateTunnelMetricBucketUniqueIndex func getSchemaVersion(db *gorm.DB) int { var v model.SchemaVersion if err := db.First(&v).Error; err != nil { db.Create(&model.SchemaVersion{Version: 0}) return 0 } return v.Version } func setSchemaVersion(db *gorm.DB, ver int) { db.Model(&model.SchemaVersion{}).Where("1=1").Update("version", ver) } func migrateSchema(db *gorm.DB) error { if db == nil { return errors.New("nil db") } if err := ensurePostgresIDDefaultsFn(db); err != nil { return err } ver := getSchemaVersion(db) if ver >= currentSchemaVersion { return nil } // Normalize strategy columns normalizeStrategy := func(modelRef interface{}, table, defaultValue string) error { result := db.Model(modelRef).Where("strategy IS NULL").Update("strategy", defaultValue) if result.Error != nil { msg := strings.ToLower(result.Error.Error()) if strings.Contains(msg, "no such table") || (strings.Contains(msg, "relation") && strings.Contains(msg, "does not exist")) { return nil } return fmt.Errorf("normalize %s.strategy: %w", table, result.Error) } return nil } if err := normalizeStrategy(&model.Forward{}, "forward", "fifo"); err != nil { return err } if err := normalizeStrategy(&model.ChainTunnel{}, "chain_tunnel", "round"); err != nil { return err } if err := normalizeStrategy(&model.PeerShareRuntime{}, "peer_share_runtime", "round"); err != nil { return err } if ver < 3 { if err := migrateViteConfigValueColumnTypeFn(db); err != nil { return err } } if ver < 4 { if err := migrateSpeedLimitTunnelBindingFn(db); err != nil { return err } } if ver < 5 { if err := migratePostgresTrafficInt64ColumnsFn(db); err != nil { return err } } if ver < 6 { if err := migrateTunnelMetricBucketUniqueIndexFn(db); err != nil { return err } } setSchemaVersion(db, currentSchemaVersion) return nil } func migrateViteConfigValueColumnType(db *gorm.DB) error { if db == nil { return errors.New("nil db") } if !db.Migrator().HasTable(&model.ViteConfig{}) { return nil } if db.Dialector.Name() != "postgres" { return nil } type columnRow struct { DataType string `gorm:"column:data_type"` } var row columnRow if err := db.Raw( `SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`, "vite_config", "value", ).Scan(&row).Error; err != nil { return fmt.Errorf("inspect vite_config.value type: %w", err) } if strings.EqualFold(row.DataType, "text") { return nil } if err := db.Exec(`ALTER TABLE "vite_config" ALTER COLUMN "value" TYPE TEXT`).Error; err != nil { return fmt.Errorf("alter vite_config.value to text: %w", err) } return nil } func migrateSpeedLimitTunnelBinding(db *gorm.DB) error { if db == nil { return errors.New("nil db") } if !db.Migrator().HasTable(&model.SpeedLimit{}) { return nil } if err := db.Model(&model.SpeedLimit{}). Where("tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL"). UpdateColumns(map[string]interface{}{ "tunnel_id": nil, "tunnel_name": nil, }).Error; err != nil { return fmt.Errorf("clear speed_limit tunnel binding: %w", err) } return nil } func migratePostgresTrafficInt64Columns(db *gorm.DB) error { if db == nil { return errors.New("nil db") } if db.Dialector.Name() != "postgres" { return nil } type trafficColumn struct { TableName string ColumnName string } columns := []trafficColumn{ {TableName: "user", ColumnName: "flow"}, {TableName: "user", ColumnName: "in_flow"}, {TableName: "user", ColumnName: "out_flow"}, {TableName: "forward", ColumnName: "in_flow"}, {TableName: "forward", ColumnName: "out_flow"}, {TableName: "statistics_flow", ColumnName: "flow"}, {TableName: "statistics_flow", ColumnName: "total_flow"}, {TableName: "tunnel", ColumnName: "flow"}, {TableName: "user_tunnel", ColumnName: "flow"}, {TableName: "user_tunnel", ColumnName: "in_flow"}, {TableName: "user_tunnel", ColumnName: "out_flow"}, {TableName: "peer_share", ColumnName: "max_bandwidth"}, {TableName: "peer_share", ColumnName: "current_flow"}, } for _, column := range columns { if err := alterPostgresColumnToBigIntIfNeeded(db, column.TableName, column.ColumnName); err != nil { return err } } return nil } func migrateTunnelMetricBucketUniqueIndex(db *gorm.DB) error { if db == nil { return errors.New("nil db") } if !db.Migrator().HasTable(&model.TunnelMetric{}) { return nil } return db.Transaction(func(tx *gorm.DB) error { // Only do the heavier dedupe work when needed. var dupGroups int64 q := ` SELECT COUNT(1) AS cnt FROM ( SELECT 1 FROM tunnel_metric GROUP BY tunnel_id, node_id, timestamp HAVING COUNT(*) > 1 ) t ` if err := tx.Raw(q).Scan(&dupGroups).Error; err != nil { return fmt.Errorf("inspect tunnel_metric duplicates: %w", err) } if dupGroups > 0 { switch tx.Dialector.Name() { case "postgres": sql := ` WITH agg AS ( SELECT MIN(id) AS keep_id, tunnel_id, node_id, timestamp, SUM(bytes_in) AS bytes_in, SUM(bytes_out) AS bytes_out, SUM(connections) AS connections, SUM(errors) AS errors, AVG(avg_latency_ms) AS avg_latency_ms FROM tunnel_metric GROUP BY tunnel_id, node_id, timestamp HAVING COUNT(*) > 1 ), updated AS ( UPDATE tunnel_metric tm SET bytes_in = agg.bytes_in, bytes_out = agg.bytes_out, connections = agg.connections, errors = agg.errors, avg_latency_ms = agg.avg_latency_ms FROM agg WHERE tm.id = agg.keep_id RETURNING tm.id ) DELETE FROM tunnel_metric tm USING agg WHERE tm.tunnel_id = agg.tunnel_id AND tm.node_id = agg.node_id AND tm.timestamp = agg.timestamp AND tm.id <> agg.keep_id ` if err := tx.Exec(sql).Error; err != nil { return fmt.Errorf("dedupe tunnel_metric buckets: %w", err) } default: // SQLite (and other) path. if err := tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error; err != nil { return fmt.Errorf("prepare tunnel_metric dedupe table: %w", err) } if err := tx.Exec(` CREATE TEMP TABLE tunnel_metric_dedupe AS SELECT MIN(id) AS keep_id, tunnel_id, node_id, timestamp, SUM(bytes_in) AS bytes_in, SUM(bytes_out) AS bytes_out, SUM(connections) AS connections, SUM(errors) AS errors, AVG(avg_latency_ms) AS avg_latency_ms FROM tunnel_metric GROUP BY tunnel_id, node_id, timestamp HAVING COUNT(*) > 1 `).Error; err != nil { return fmt.Errorf("build tunnel_metric dedupe table: %w", err) } if err := tx.Exec(` UPDATE tunnel_metric SET bytes_in = (SELECT bytes_in FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), bytes_out = (SELECT bytes_out FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), connections = (SELECT connections FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), errors = (SELECT errors FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), avg_latency_ms = (SELECT avg_latency_ms FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id) WHERE id IN (SELECT keep_id FROM tunnel_metric_dedupe) `).Error; err != nil { return fmt.Errorf("update tunnel_metric deduped rows: %w", err) } if err := tx.Exec(` DELETE FROM tunnel_metric WHERE id IN ( SELECT tm.id FROM tunnel_metric tm JOIN tunnel_metric_dedupe d ON tm.tunnel_id = d.tunnel_id AND tm.node_id = d.node_id AND tm.timestamp = d.timestamp WHERE tm.id <> d.keep_id ) `).Error; err != nil { return fmt.Errorf("delete tunnel_metric duplicates: %w", err) } _ = tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error } } // Uniqueness is required for safe upsert on (tunnel_id, node_id, timestamp). if err := tx.Exec( `CREATE UNIQUE INDEX IF NOT EXISTS uidx_tunnel_metric_bucket ON tunnel_metric(tunnel_id, node_id, timestamp)`, ).Error; err != nil { return fmt.Errorf("create tunnel_metric unique index: %w", err) } return nil }) } func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error { if db == nil { return errors.New("nil db") } if tableName == "" || columnName == "" { return errors.New("empty table or column name") } type columnRow struct { DataType string `gorm:"column:data_type"` } var row columnRow if err := db.Raw( `SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`, tableName, columnName, ).Scan(&row).Error; err != nil { return fmt.Errorf("inspect %s.%s type: %w", tableName, columnName, err) } if row.DataType == "" || strings.EqualFold(row.DataType, "bigint") { return nil } if !strings.EqualFold(row.DataType, "integer") { return nil } if err := db.Exec(fmt.Sprintf( "ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT", quoteSQLIdentifier(tableName), quoteSQLIdentifier(columnName), )).Error; err != nil { return fmt.Errorf("alter %s.%s to bigint: %w", tableName, columnName, err) } return nil } func ensurePostgresIDDefaults(db *gorm.DB) error { if db.Dialector.Name() != "postgres" { return nil } type idRow struct { TableSchema string TableName string } var rows []idRow err := db.Table("information_schema.table_constraints AS tc"). Select("c.table_schema, c.table_name"). Joins("JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema"). Joins("JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name"). Where("tc.constraint_type = ?", "PRIMARY KEY"). Where("kcu.column_name = ?", "id"). Where("c.data_type IN ?", []string{"integer", "bigint"}). Where("c.is_identity = ?", "NO"). Where("c.table_schema = current_schema()"). Order("c.table_name ASC"). Scan(&rows).Error if err != nil { return fmt.Errorf("discover postgres id columns: %w", err) } for _, r := range rows { if err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil { return fmt.Errorf("repair %s.%s id default: %w", r.TableSchema, r.TableName, err) } } return nil } func ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error { type defaultRow struct { ColumnDefault sql.NullString `gorm:"column:column_default"` } var row defaultRow err := db.Table("information_schema.columns"). Select("column_default"). Where("table_schema = ? AND table_name = ? AND column_name = 'id'", schemaName, tableName). Limit(1). Scan(&row).Error if err != nil { return err } defaultExpr := row.ColumnDefault hasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), "nextval(") seqRef := "" if hasNextvalDefault { seqRef = extractNextvalRegclass(defaultExpr.String) } if !hasNextvalDefault || seqRef == "" { seqName := tableName + "_id_seq" if err := db.Exec(fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s.%s", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil { return err } seqRef = schemaName + "." + seqName if err := db.Exec(fmt.Sprintf( "ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef), )).Error; err != nil { return err } if err := db.Exec(fmt.Sprintf( "ALTER SEQUENCE %s.%s OWNED BY %s.%s.id", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName), quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), )).Error; err != nil { return err } } return syncPostgresTableIDSequence(db, schemaName, tableName, seqRef) } func syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error { type maxRow struct { MaxID int64 `gorm:"column:max_id"` } var row maxRow qualifiedTable := fmt.Sprintf("%s.%s", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName)) err := db.Table(qualifiedTable). Select("COALESCE(MAX(id), 0) AS max_id"). Scan(&row).Error if err != nil { return err } maxID := row.MaxID setVal := maxID isCalled := true if maxID <= 0 { setVal = 1 isCalled = false } return db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error } func extractNextvalRegclass(defaultExpr string) string { nextvalIdx := strings.Index(strings.ToLower(defaultExpr), "nextval(") if nextvalIdx < 0 { return "" } expr := defaultExpr[nextvalIdx:] firstQuote := strings.Index(expr, "'") if firstQuote < 0 { return "" } expr = expr[firstQuote+1:] secondQuote := strings.Index(expr, "'") if secondQuote < 0 { return "" } return strings.TrimSpace(expr[:secondQuote]) } func quoteSQLIdentifier(ident string) string { return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"` } func quoteSQLLiteral(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } // ─── Helper Functions ──────────────────────────────────────────────── func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) { var tunnelInIP sql.NullString db.Model(&model.Tunnel{}).Select("in_ip").Where("id = ?", tunnelID).Limit(1).Scan(&tunnelInIP) type fpRow struct { Port sql.NullInt64 ServerIP sql.NullString InIP sql.NullString } var fpRows []fpRow err := db.Model(&model.ForwardPort{}). Select("forward_port.port, node.server_ip, forward_port.in_ip"). Joins("LEFT JOIN node ON node.id = forward_port.node_id"). Where("forward_port.forward_id = ?", forwardID). Order("forward_port.id ASC"). Find(&fpRows).Error if err != nil { return "", sql.NullInt64{}, err } ports := make([]int64, 0) entries := make([]string, 0) seenPorts := make(map[int64]struct{}) seenPairs := make(map[string]struct{}) for _, row := range fpRows { if !row.Port.Valid { continue } if _, ok := seenPorts[row.Port.Int64]; !ok { seenPorts[row.Port.Int64] = struct{}{} ports = append(ports, row.Port.Int64) } var ip string if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" { ip = strings.TrimSpace(row.InIP.String) } else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" { ip = strings.TrimSpace(row.ServerIP.String) } if ip != "" { pair := formatForwardIngressAddress(ip, row.Port.Int64) if _, ok := seenPairs[pair]; !ok { seenPairs[pair] = struct{}{} entries = append(entries, pair) } } } if len(ports) == 0 { return "", sql.NullInt64{}, nil } inPort := sql.NullInt64{Int64: ports[0], Valid: true} return strings.Join(entries, ","), inPort, nil } func formatForwardIngressAddress(host string, port int64) string { host = strings.TrimSpace(host) if host == "" || port <= 0 { return "" } if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") } return net.JoinHostPort(host, strconv.FormatInt(port, 10)) } func nullableString(v sql.NullString) interface{} { if v.Valid { return v.String } return nil } func nullableForwardIngress(v string) interface{} { v = strings.TrimSpace(v) if v == "" { return nil } return v } func nullableInt64(v sql.NullInt64) interface{} { if v.Valid { return v.Int64 } return nil } func unixMilliNow() int64 { return time.Now().UnixMilli() } func ensureParentDir(dbPath string) error { if dbPath == "" { return fmt.Errorf("empty db path") } dir := filepath.Dir(dbPath) if dir == "" || dir == "." { return nil } return osMkdirAll(dir) } var osMkdirAll = func(path string) error { return os.MkdirAll(path, 0o755) } // Suppress unused import warning for log var _ = log.Printf func (r *Repository) InsertNodeMetric(m *model.NodeMetric) error { if r == nil || r.db == nil { return nil } return r.db.Create(m).Error } func (r *Repository) InsertNodeMetricBatch(metrics []*model.NodeMetric) error { if r == nil || r.db == nil || len(metrics) == 0 { return nil } return r.db.CreateInBatches(metrics, 100).Error } func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) { if r == nil || r.db == nil { return nil, nil } rangeMs := endMs - startMs const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges const targetPoints = 500 // target number of chart points for downsampled data // For short ranges, return raw data (full resolution). if rangeMs <= maxRawRangeMs { var metrics []model.NodeMetric err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs). Order("timestamp ASC"). Limit(5000). Find(&metrics).Error return metrics, err } // For longer ranges, downsample via SQL aggregation to keep the response small and fast. bucketMs := rangeMs / targetPoints if bucketMs < 1000 { bucketMs = 1000 // minimum 1-second buckets } bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs) groupExpr := fmt.Sprintf("timestamp / %d", bucketMs) var metrics []model.NodeMetric err := r.db.Model(&model.NodeMetric{}). Select( fmt.Sprintf( "%d AS node_id, "+ "CAST(%s AS BIGINT) AS timestamp, "+ "AVG(cpu_usage) AS cpu_usage, "+ "AVG(mem_usage) AS mem_usage, "+ "AVG(disk_usage) AS disk_usage, "+ "CAST(AVG(net_in_bytes) AS BIGINT) AS net_in_bytes, "+ "CAST(AVG(net_out_bytes) AS BIGINT) AS net_out_bytes, "+ "CAST(AVG(net_in_speed) AS BIGINT) AS net_in_speed, "+ "CAST(AVG(net_out_speed) AS BIGINT) AS net_out_speed, "+ "AVG(load1) AS load1, "+ "AVG(load5) AS load5, "+ "AVG(load15) AS load15, "+ "CAST(AVG(tcp_conns) AS BIGINT) AS tcp_conns, "+ "CAST(AVG(udp_conns) AS BIGINT) AS udp_conns, "+ "CAST(MAX(uptime) AS BIGINT) AS uptime", nodeID, bucketExpr, ), ). Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs). Group(groupExpr). Order("timestamp ASC"). Limit(targetPoints + 100). // safety margin Scan(&metrics).Error if metrics == nil { metrics = make([]model.NodeMetric, 0) } return metrics, err } func (r *Repository) GetLatestNodeMetric(nodeID int64) (*model.NodeMetric, error) { if r == nil || r.db == nil { return nil, nil } var m model.NodeMetric err := r.db.Where("node_id = ?", nodeID).Order("timestamp DESC").First(&m).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return &m, nil } func (r *Repository) PruneNodeMetrics(olderThanMs int64) error { if r == nil || r.db == nil { return nil } return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.NodeMetric{}).Error } func (r *Repository) InsertTunnelMetric(m *model.TunnelMetric) error { if r == nil || r.db == nil { return nil } return r.db.Create(m).Error } func (r *Repository) InsertTunnelMetricBatch(metrics []*model.TunnelMetric) error { if r == nil || r.db == nil || len(metrics) == 0 { return nil } return r.db.CreateInBatches(metrics, 100).Error } // UpsertTunnelMetricBuckets adds the provided metric deltas into per-minute buckets. // Requires a unique index on (tunnel_id, node_id, timestamp) for safe upserts. func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) error { if r == nil || r.db == nil || len(metrics) == 0 { return nil } // Postgres rejects a single INSERT ... ON CONFLICT when the input contains // duplicate conflict keys. Pre-aggregate within this batch to keep inserts safe. type bucketKey struct { tunnelID int64 nodeID int64 timestamp int64 } agg := make(map[bucketKey]*model.TunnelMetric, len(metrics)) for _, m := range metrics { if m == nil { continue } if m.TunnelID <= 0 || m.NodeID <= 0 || m.Timestamp <= 0 { continue } if m.BytesIn == 0 && m.BytesOut == 0 && m.Connections == 0 && m.Errors == 0 { continue } k := bucketKey{tunnelID: m.TunnelID, nodeID: m.NodeID, timestamp: m.Timestamp} if existing, ok := agg[k]; ok { existing.BytesIn += m.BytesIn existing.BytesOut += m.BytesOut existing.Connections += m.Connections existing.Errors += m.Errors if existing.AvgLatencyMs == 0 && m.AvgLatencyMs != 0 { existing.AvgLatencyMs = m.AvgLatencyMs } continue } cp := *m agg[k] = &cp } if len(agg) == 0 { return nil } rows := make([]*model.TunnelMetric, 0, len(agg)) for _, v := range agg { rows = append(rows, v) } return r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}}, DoUpdates: clause.Assignments(map[string]interface{}{ "bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"), "bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"), "connections": gorm.Expr("tunnel_metric.connections + excluded.connections"), "errors": gorm.Expr("tunnel_metric.errors + excluded.errors"), // avg_latency_ms is not additive; keep the existing bucket value. }), }).CreateInBatches(rows, 100).Error } func (r *Repository) GetTunnelMetrics(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) { if r == nil || r.db == nil { return nil, nil } var metrics []model.TunnelMetric err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs). Order("timestamp DESC"). Limit(5000). Find(&metrics).Error if len(metrics) > 1 { for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 { metrics[i], metrics[j] = metrics[j], metrics[i] } } return metrics, err } // GetTunnelMetricsAggregated returns tunnel-level aggregated series (one point per timestamp). // Storage remains per (tunnel_id, node_id, timestamp) for future drill-down. func (r *Repository) GetTunnelMetricsAggregated(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) { if r == nil || r.db == nil { return nil, nil } var metrics []model.TunnelMetric err := r.db.Model(&model.TunnelMetric{}). Select( "tunnel_id, 0 AS node_id, timestamp, "+ "SUM(bytes_in) AS bytes_in, "+ "SUM(bytes_out) AS bytes_out, "+ "SUM(connections) AS connections, "+ "SUM(errors) AS errors, "+ "AVG(avg_latency_ms) AS avg_latency_ms", ). Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs). Group("tunnel_id, timestamp"). Order("timestamp ASC"). Limit(5000). Scan(&metrics).Error if metrics == nil { metrics = make([]model.TunnelMetric, 0) } return metrics, err } func (r *Repository) PruneTunnelMetrics(olderThanMs int64) error { if r == nil || r.db == nil { return nil } return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelMetric{}).Error } func (r *Repository) ListServiceMonitors() ([]model.ServiceMonitor, error) { if r == nil || r.db == nil { return nil, nil } var monitors []model.ServiceMonitor err := r.db.Order("id ASC").Find(&monitors).Error return monitors, err } func (r *Repository) ListEnabledServiceMonitors() ([]model.ServiceMonitor, error) { if r == nil || r.db == nil { return nil, nil } var monitors []model.ServiceMonitor err := r.db.Where("enabled = 1 AND type IN (?)", []string{"tcp", "icmp"}).Order("id ASC").Find(&monitors).Error return monitors, err } func (r *Repository) GetServiceMonitor(id int64) (*model.ServiceMonitor, error) { if r == nil || r.db == nil { return nil, nil } var m model.ServiceMonitor err := r.db.First(&m, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return &m, nil } func (r *Repository) CreateServiceMonitor(m *model.ServiceMonitor) error { if r == nil || r.db == nil { return nil } return r.db.Create(m).Error } func (r *Repository) UpdateServiceMonitor(m *model.ServiceMonitor) error { if r == nil || r.db == nil { return nil } return r.db.Save(m).Error } func (r *Repository) DeleteServiceMonitor(id int64) error { if r == nil || r.db == nil { return nil } if id <= 0 { return nil } // Keep API/UI semantics simple: deleting a monitor also deletes its history. return r.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("monitor_id = ?", id).Delete(&model.ServiceMonitorResult{}).Error; err != nil { return err } return tx.Delete(&model.ServiceMonitor{}, id).Error }) } func (r *Repository) InsertServiceMonitorResult(result *model.ServiceMonitorResult) error { if r == nil || r.db == nil { return nil } return r.db.Create(result).Error } func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]model.ServiceMonitorResult, error) { if r == nil || r.db == nil { return nil, nil } if limit <= 0 { limit = 100 } var results []model.ServiceMonitorResult err := r.db.Where("monitor_id = ?", monitorID). Order("timestamp DESC"). Limit(limit). Find(&results).Error return results, err } // GetServiceMonitorResultsByTimeRange returns results for a monitor within [startMs, endMs]. // Mirrors GetNodeMetrics / GetTunnelMetrics pattern for time-range based charting. func (r *Repository) GetServiceMonitorResultsByTimeRange(monitorID int64, startMs, endMs int64) ([]model.ServiceMonitorResult, error) { if r == nil || r.db == nil { return nil, nil } var results []model.ServiceMonitorResult err := r.db.Where("monitor_id = ? AND timestamp >= ? AND timestamp <= ?", monitorID, startMs, endMs). Order("timestamp DESC"). Limit(5000). Find(&results).Error if len(results) > 1 { for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 { results[i], results[j] = results[j], results[i] } } return results, err } // GetLatestServiceMonitorResults returns the newest result per monitor_id. // This is intended for list rendering (avoid N+1 queries). func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) { if r == nil || r.db == nil { return nil, nil } var results []model.ServiceMonitorResult // Prefer a window-function query (works on modern SQLite + Postgres). q1 := ` SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY timestamp DESC, id DESC) AS rn FROM service_monitor_result ) t WHERE rn = 1 ORDER BY monitor_id ASC ` if err := r.db.Raw(q1).Scan(&results).Error; err == nil { return results, nil } // Fallback: just return newest rows (best-effort). This avoids hard failure on older SQLite builds. // Note: This may not include all monitors if the table is extremely large and skewed. results = nil q2 := ` SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message FROM service_monitor_result ORDER BY timestamp DESC, id DESC LIMIT 5000 ` err := r.db.Raw(q2).Scan(&results).Error if err != nil { return nil, err } seen := make(map[int64]struct{}, len(results)) out := make([]model.ServiceMonitorResult, 0, len(results)) for _, row := range results { if row.MonitorID <= 0 { continue } if _, ok := seen[row.MonitorID]; ok { continue } seen[row.MonitorID] = struct{}{} out = append(out, row) } // Keep response stable for the frontend. sort.Slice(out, func(i, j int) bool { return out[i].MonitorID < out[j].MonitorID }) return out, nil } func (r *Repository) PruneServiceMonitorResults(olderThanMs int64) error { if r == nil || r.db == nil { return nil } return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.ServiceMonitorResult{}).Error }