diff --git a/internal/services/pyrodactyl.go b/internal/services/pyrodactyl.go new file mode 100644 index 0000000..2063c68 --- /dev/null +++ b/internal/services/pyrodactyl.go @@ -0,0 +1,739 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +var ( + PteroURL string + PteroAPIKey string + ServerLimits = map[string]interface{}{ + "memory": 512, + "swap": 0, + "disk": 3072, + "io": 500, + "cpu": 50, + } + FeatureLimits = map[string]interface{}{ + "databases": 0, + "allocations": 1, + "backups": 1, + } + DeployLocations = []int64{1} + PanelDBName string +) + +type cacheEntry struct { + data interface{} + timestamp time.Time +} + +var ( + nodeCache = make(map[int64]cacheEntry) + cacheMu sync.RWMutex + cacheTTL = 5 * time.Minute + cacheMax = 50 + cleanupOnce sync.Once +) + +func initCache() { + cleanupOnce.Do(func() { + go func() { + for { + time.Sleep(cacheTTL) + cacheMu.Lock() + cutoff := time.Now().Add(-cacheTTL) + for k, v := range nodeCache { + if v.timestamp.Before(cutoff) { + delete(nodeCache, k) + } + } + cacheMu.Unlock() + } + }() + }) +} + +func pteroFetch(method, path string, body interface{}) ([]byte, error) { + url := PteroURL + "/api/application" + path + maxRetries := 3 + + for attempt := 1; attempt <= maxRetries; attempt++ { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+PteroAPIKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + if attempt < maxRetries { + wait := time.Duration(1000*(1<<(attempt-1))) * time.Millisecond + if wait > 8*time.Second { + wait = 8 * time.Second + } + time.Sleep(wait) + continue + } + return nil, fmt.Errorf("pteroFetch error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 204 { + return nil, nil + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode == 429 && attempt < maxRetries { + wait := time.Duration(1000*(1<<(attempt-1))) * time.Millisecond + if wait > 8*time.Second { + wait = 8 * time.Second + } + time.Sleep(wait) + continue + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("Pterodactyl API error %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 200)])) + } + + return respBody, nil + } + return nil, fmt.Errorf("pteroFetch failed after %d retries", maxRetries) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type pteroResponse struct { + Attributes json.RawMessage `json:"attributes"` + Data []pteroItem `json:"data"` + Meta *pteroMeta `json:"meta"` +} + +type pteroItem struct { + Attributes json.RawMessage `json:"attributes"` +} + +type pteroMeta struct { + Pagination *pteroPagination `json:"pagination"` +} + +type pteroPagination struct { + Total int `json:"total"` + Count int `json:"count"` + PerPage int `json:"per_page"` + CurrentPage int `json:"current_page"` + TotalPages int `json:"total_pages"` +} + +func fetchPteroPage(path string) (*pteroResponse, error) { + b, err := pteroFetch("GET", path, nil) + if err != nil { + return nil, err + } + var resp pteroResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func CreatePteroUser(email, username, firstName, lastName, password string) (map[string]interface{}, error) { + body := map[string]interface{}{ + "email": email, + "username": username, + "first_name": firstName, + "last_name": lastName, + "password": password, + "language": "en", + "root_admin": false, + } + b, err := pteroFetch("POST", "/users", body) + if err != nil { + return nil, err + } + var resp pteroResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + var attrs map[string]interface{} + json.Unmarshal(resp.Attributes, &attrs) + return attrs, nil +} + +func GetPteroUserByID(id int64) (map[string]interface{}, error) { + b, err := pteroFetch("GET", fmt.Sprintf("/users/%d", id), nil) + if err != nil { + return nil, err + } + var resp pteroResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + var attrs map[string]interface{} + json.Unmarshal(resp.Attributes, &attrs) + return attrs, nil +} + +func getNode(nodeID int64) (map[string]interface{}, error) { + cacheMu.RLock() + entry, ok := nodeCache[nodeID] + cacheMu.RUnlock() + if ok && time.Since(entry.timestamp) < cacheTTL { + return entry.data.(map[string]interface{}), nil + } + + b, err := pteroFetch("GET", fmt.Sprintf("/nodes/%d", nodeID), nil) + if err != nil { + return nil, err + } + var resp pteroResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + var attrs map[string]interface{} + json.Unmarshal(resp.Attributes, &attrs) + + cacheMu.Lock() + if len(nodeCache) >= cacheMax { + for k := range nodeCache { + delete(nodeCache, k) + break + } + } + nodeCache[nodeID] = cacheEntry{data: attrs, timestamp: time.Now()} + cacheMu.Unlock() + + return attrs, nil +} + +func GetEgg(nestID, eggID int64) (map[string]interface{}, error) { + b, err := pteroFetch("GET", fmt.Sprintf("/nests/%d/eggs/%d?include=variables", nestID, eggID), nil) + if err != nil { + return nil, err + } + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return nil, err + } + var attrs map[string]interface{} + if a, ok := raw["attributes"].(map[string]interface{}); ok { + attrs = a + } else { + return nil, fmt.Errorf("unexpected egg response format") + } + + if rels, ok := raw["relationships"].(map[string]interface{}); ok { + if vars, ok := rels["variables"].(map[string]interface{}); ok { + if data, ok := vars["data"].([]interface{}); ok { + envMap := make(map[string]interface{}) + for _, item := range data { + if entry, ok := item.(map[string]interface{}); ok { + if varAttrs, ok := entry["attributes"].(map[string]interface{}); ok { + envVar, _ := varAttrs["env_variable"].(string) + defaultVal, _ := varAttrs["default_value"].(string) + if envVar != "" { + envMap[envVar] = defaultVal + } + } + } + } + attrs["environment"] = envMap + } + } + } + return attrs, nil +} + +func GetPteroNests() ([]map[string]interface{}, error) { + b, err := pteroFetch("GET", "/nests?per_page=100", nil) + if err != nil { + return nil, err + } + var resp pteroResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + var nests []map[string]interface{} + for _, item := range resp.Data { + var attrs map[string]interface{} + json.Unmarshal(item.Attributes, &attrs) + nests = append(nests, attrs) + } + return nests, nil +} + +func GetPteroNestEggs(nestID int64) ([]map[string]interface{}, error) { + b, err := pteroFetch("GET", fmt.Sprintf("/nests/%d/eggs?per_page=100", nestID), nil) + if err != nil { + return nil, err + } + var resp pteroResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + var eggs []map[string]interface{} + for _, item := range resp.Data { + var attrs map[string]interface{} + json.Unmarshal(item.Attributes, &attrs) + eggs = append(eggs, attrs) + } + return eggs, nil +} + +func paginateAll(path string) ([]map[string]interface{}, error) { + var all []map[string]interface{} + page := 1 + maxPages := 20 + + for page <= maxPages { + resp, err := fetchPteroPage(fmt.Sprintf("%s?page=%d&per_page=50", path, page)) + if err != nil { + return nil, err + } + for _, item := range resp.Data { + var attrs map[string]interface{} + json.Unmarshal(item.Attributes, &attrs) + all = append(all, attrs) + } + if resp.Meta == nil || resp.Meta.Pagination == nil || page >= resp.Meta.Pagination.TotalPages { + break + } + page++ + } + return all, nil +} + +func enrichServer(server map[string]interface{}) { + if nodeID, ok := server["node"].(float64); ok { + node, err := getNode(int64(nodeID)) + if err == nil { + server["nodeFqdn"] = node["fqdn"] + } else { + server["nodeFqdn"] = nil + } + } + if alloc, ok := server["allocation"].(float64); ok && alloc > 0 { + if nodeID, ok := server["node"].(float64); ok { + b, err := pteroFetch("GET", fmt.Sprintf("/nodes/%d/allocations/%d", int64(nodeID), int64(alloc)), nil) + if err == nil { + var resp pteroResponse + json.Unmarshal(b, &resp) + var allocAttrs map[string]interface{} + json.Unmarshal(resp.Attributes, &allocAttrs) + allocAttrs["nodeFqdn"] = server["nodeFqdn"] + server["allocationDetails"] = allocAttrs + } else { + server["allocationDetails"] = nil + } + } + } + if nest, ok := server["nest"].(float64); ok { + if egg, ok := server["egg"].(float64); ok { + eggData, err := GetEgg(int64(nest), int64(egg)) + if err == nil { + server["eggDetails"] = map[string]interface{}{"name": eggData["name"]} + } else { + server["eggDetails"] = nil + } + } + } +} + +func GetAllServers(limit, offset *int) (map[string]interface{}, error) { + all, err := paginateAll("/servers") + if err != nil { + return nil, err + } + + for i := 0; i < len(all)-1; i++ { + for j := i + 1; j < len(all); j++ { + ti, _ := all[i]["created_at"].(string) + tj, _ := all[j]["created_at"].(string) + if ti < tj { + all[i], all[j] = all[j], all[i] + } + } + } + + total := len(all) + var toEnrich []map[string]interface{} + if limit != nil && offset != nil { + start := *offset + end := start + *limit + if start > len(all) { + start = len(all) + } + if end > len(all) { + end = len(all) + } + toEnrich = all[start:end] + } else { + toEnrich = all + } + + for _, s := range toEnrich { + enrichServer(s) + } + + if limit != nil && offset != nil { + return map[string]interface{}{"servers": toEnrich, "total": total}, nil + } + return map[string]interface{}{"servers": all, "total": total}, nil +} + +func GetServersByUser(userID int64) ([]map[string]interface{}, error) { + all, err := paginateAll("/servers") + if err != nil { + return nil, err + } + + var userServers []map[string]interface{} + for _, s := range all { + if uid, ok := s["user"].(float64); ok && int64(uid) == userID { + enrichServer(s) + userServers = append(userServers, s) + } + } + return userServers, nil +} + +func GetServerByID(serverID int64) (map[string]interface{}, error) { + b, err := pteroFetch("GET", fmt.Sprintf("/servers/%d", serverID), nil) + if err != nil { + return nil, err + } + var resp pteroResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + var server map[string]interface{} + json.Unmarshal(resp.Attributes, &server) + + enrichServer(server) + return server, nil +} + +func CreatePteroServer(params map[string]interface{}) (map[string]interface{}, error) { + limits := make(map[string]interface{}) + for k, v := range ServerLimits { + limits[k] = v + } + if custom, ok := params["customLimits"].(map[string]interface{}); ok { + for k, v := range custom { + limits[k] = v + } + } + + env, _ := params["environment"].(map[string]interface{}) + if env == nil { + env = make(map[string]interface{}) + } + + deployLocs := DeployLocations + if dl, ok := params["deployLocations"].([]int64); ok && len(dl) > 0 { + deployLocs = dl + } + + body := map[string]interface{}{ + "name": params["name"], + "user": params["userId"], + "egg": params["eggId"], + "docker_image": params["dockerImage"], + "startup": params["startup"], + "environment": env, + "limits": limits, + "feature_limits": FeatureLimits, + "deploy": map[string]interface{}{ + "locations": deployLocs, + "dedicated_ip": false, + "port_range": []string{}, + }, + "start_on_completion": true, + "skip_scripts": false, + "oom_disabled": true, + } + + b, err := pteroFetch("POST", "/servers", body) + if err != nil { + return nil, err + } + var resp pteroResponse + json.Unmarshal(b, &resp) + var server map[string]interface{} + json.Unmarshal(resp.Attributes, &server) + return server, nil +} + +func DeletePteroServer(serverID int64) error { + _, err := pteroFetch("DELETE", fmt.Sprintf("/servers/%d", serverID), nil) + return err +} + +func SuspendPteroServer(serverID int64) error { + _, err := pteroFetch("POST", fmt.Sprintf("/servers/%d/suspend", serverID), nil) + return err +} + +func UnsuspendPteroServer(serverID int64) error { + _, err := pteroFetch("POST", fmt.Sprintf("/servers/%d/unsuspend", serverID), nil) + return err +} + +func ReinstallPteroServer(serverID int64) error { + _, err := pteroFetch("POST", fmt.Sprintf("/servers/%d/reinstall", serverID), nil) + return err +} + +func UpdatePteroServerBuild(serverID int64, limits map[string]interface{}) error { + server, err := GetServerByID(serverID) + if err != nil { + return err + } + currentLimits, _ := server["limits"].(map[string]interface{}) + for k, v := range limits { + currentLimits[k] = v + } + featureLimits, _ := server["feature_limits"].(map[string]interface{}) + if featureLimits == nil { + featureLimits = map[string]interface{}{"databases": 0, "allocations": 1, "backups": 1} + } + oomDisabled := true + if od, ok := server["oom_disabled"].(bool); ok { + oomDisabled = od + } + + body := map[string]interface{}{ + "allocation": server["allocation"], + "memory": currentLimits["memory"], + "swap": currentLimits["swap"], + "disk": currentLimits["disk"], + "io": currentLimits["io"], + "cpu": currentLimits["cpu"], + "feature_limits": featureLimits, + "oom_disabled": oomDisabled, + } + _, err = pteroFetch("PATCH", fmt.Sprintf("/servers/%d/build", serverID), body) + return err +} + +func GetPergoServerIDsByEgg(nestID, eggID int64) ([]int64, error) { + all, err := paginateAll("/servers") + if err != nil { + return nil, err + } + var ids []int64 + for _, s := range all { + n, _ := s["nest"].(float64) + e, _ := s["egg"].(float64) + if int64(n) == nestID && int64(e) == eggID { + if id, ok := s["id"].(float64); ok { + ids = append(ids, int64(id)) + } + } + } + return ids, nil +} + +func RenamePteroServer(serverID int64, name string) error { + server, err := GetServerByID(serverID) + if err != nil { + return err + } + user, _ := server["user"].(float64) + body := map[string]interface{}{ + "name": name, + "user": int64(user), + } + _, err = pteroFetch("PATCH", fmt.Sprintf("/servers/%d", serverID), body) + return err +} + +func UpdatePteroPassword(userID int64, password string) error { + body := map[string]interface{}{"password": password} + _, err := pteroFetch("PATCH", fmt.Sprintf("/users/%d", userID), body) + return err +} + +func UpdatePteroEmail(userID int64, email string) error { + user, err := GetPteroUserByID(userID) + if err != nil { + return err + } + body := map[string]interface{}{ + "email": email, + "username": user["username"], + "first_name": user["first_name"], + "last_name": user["last_name"], + } + _, err = pteroFetch("PATCH", fmt.Sprintf("/users/%d", userID), body) + return err +} + +func DeletePteroUser(userID int64) error { + _, err := pteroFetch("DELETE", fmt.Sprintf("/users/%d", userID), nil) + return err +} + +func GetAllNodes() ([]map[string]interface{}, error) { + return paginateAll("/nodes") +} + +func GetNodeDetail(nodeID int64) (map[string]interface{}, error) { + b, err := pteroFetch("GET", fmt.Sprintf("/nodes/%d", nodeID), nil) + if err != nil { + return nil, err + } + var resp pteroResponse + json.Unmarshal(b, &resp) + var node map[string]interface{} + json.Unmarshal(resp.Attributes, &node) + return node, nil +} + +func GetNodeAllocations(nodeID int64) ([]map[string]interface{}, error) { + var all []map[string]interface{} + page := 1 + maxPages := 20 + for page <= maxPages { + resp, err := fetchPteroPage(fmt.Sprintf("/nodes/%d/allocations?page=%d&per_page=100", nodeID, page)) + if err != nil { + return nil, err + } + for _, item := range resp.Data { + var attrs map[string]interface{} + json.Unmarshal(item.Attributes, &attrs) + all = append(all, attrs) + } + if resp.Meta == nil || resp.Meta.Pagination == nil || page >= resp.Meta.Pagination.TotalPages { + break + } + page++ + } + return all, nil +} + +func GetNodeServers(nodeID int64) ([]map[string]interface{}, error) { + var all []map[string]interface{} + page := 1 + maxPages := 20 + for page <= maxPages { + resp, err := fetchPteroPage(fmt.Sprintf("/nodes/%d/servers?page=%d&per_page=50", nodeID, page)) + if err != nil { + return nil, err + } + for _, item := range resp.Data { + var attrs map[string]interface{} + json.Unmarshal(item.Attributes, &attrs) + all = append(all, attrs) + } + if resp.Meta == nil || resp.Meta.Pagination == nil || page >= resp.Meta.Pagination.TotalPages { + break + } + page++ + } + return all, nil +} + +func GetAllEggs(nestIDs []int64) ([]map[string]interface{}, error) { + var all []map[string]interface{} + for _, nestID := range nestIDs { + eggs, err := GetPteroNestEggs(nestID) + if err != nil { + continue + } + for _, egg := range eggs { + full, err := GetEgg(nestID, int64(egg["id"].(float64))) + if err != nil { + continue + } + all = append(all, map[string]interface{}{ + "nest": nestID, + "egg": full, + }) + } + } + return all, nil +} + +func GetClientServerResources(identifier, apiKey string) (map[string]interface{}, error) { + client := &http.Client{Timeout: 8 * time.Second} + req, err := http.NewRequest("GET", PteroURL+"/api/client/servers/"+identifier+"/resources", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + json.Unmarshal(b, &result) + return result, nil +} + +func SendPowerSignal(identifier, apiKey, signal string) error { + body := map[string]string{"signal": signal} + b, _ := json.Marshal(body) + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("POST", PteroURL+"/api/client/servers/"+identifier+"/power", bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("power signal failed with status %d", resp.StatusCode) + } + return nil +} + +func TestPteroConnection() error { + _, err := pteroFetch("GET", "/servers?per_page=1", nil) + return err +} diff --git a/internal/services/scheduler.go b/internal/services/scheduler.go new file mode 100644 index 0000000..c02df5c --- /dev/null +++ b/internal/services/scheduler.go @@ -0,0 +1,156 @@ +package services + +import ( + "fmt" + "log" + "sync" + "time" + + "zerohost/dashboard/internal/database" +) + +var ( + schedulerRunning bool + schedulerMu sync.Mutex + stopChan chan struct{} +) + +func StartScheduler() { + schedulerMu.Lock() + if schedulerRunning { + schedulerMu.Unlock() + return + } + schedulerRunning = true + stopChan = make(chan struct{}) + schedulerMu.Unlock() + + go func() { + log.Println("Server lifetime scheduler started") + suspendExpiredServers() + + for { + next := msUntilMidnight() + select { + case <-time.After(next): + suspendExpiredServers() + cleanupOldNotifications() + cleanupOldActivityLogs() + case <-stopChan: + return + } + } + }() +} + +func StopScheduler() { + schedulerMu.Lock() + defer schedulerMu.Unlock() + if schedulerRunning { + schedulerRunning = false + close(stopChan) + } +} + +func msUntilMidnight() time.Duration { + now := time.Now() + midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + return midnight.Sub(now) +} + +func suspendExpiredServers() { + rows, err := database.DB.Query( + "SELECT * FROM server_meta WHERE expires_at <= NOW() AND status = 'active'", + ) + if err != nil { + log.Printf("Scheduler check error: %v", err) + return + } + defer rows.Close() + + var expired []map[string]interface{} + cols, _ := rows.Columns() + for rows.Next() { + vals := make([]interface{}, len(cols)) + valPtrs := make([]interface{}, len(cols)) + for i := range vals { + valPtrs[i] = &vals[i] + } + rows.Scan(valPtrs...) + row := make(map[string]interface{}) + for i, col := range cols { + row[col] = vals[i] + } + expired = append(expired, row) + } + + for _, row := range expired { + pteroID := toInt64(row["ptero_server_id"]) + id := toInt64(row["id"]) + userID := toInt64(row["user_id"]) + + if err := SuspendPteroServer(pteroID); err != nil { + log.Printf("Failed to suspend server %d: %v", pteroID, err) + continue + } + + database.DB.Exec("UPDATE server_meta SET status = 'suspended' WHERE id = ?", id) + CreateNotification(userID, "Server Expired", + fmt.Sprintf("Your server #%d has been suspended due to expiry. Renew it to reactivate.", pteroID), + "warning", nil) + log.Printf("Suspended server %d (expired)", pteroID) + } + + if len(expired) > 0 { + log.Printf("Suspended %d expired server(s)", len(expired)) + } +} + +func cleanupOldNotifications() { + res, err := database.DB.Exec( + "DELETE FROM notifications WHERE created_at < NOW() - INTERVAL 90 DAY AND is_read = 1", + ) + if err != nil { + log.Printf("Notification cleanup error: %v", err) + return + } + if n, _ := res.RowsAffected(); n > 0 { + log.Printf("Cleaned up %d old read notification(s)", n) + } +} + +func cleanupOldActivityLogs() { + res, err := database.DB.Exec( + "DELETE FROM activity_log WHERE created_at < NOW() - INTERVAL 90 DAY", + ) + if err != nil { + log.Printf("Activity log cleanup error: %v", err) + return + } + if n, _ := res.RowsAffected(); n > 0 { + log.Printf("Cleaned up %d old activity log(s)", n) + } +} + +func toInt64(v interface{}) int64 { + if v == nil { + return 0 + } + switch val := v.(type) { + case int64: + return val + case float64: + return int64(val) + case []byte: + n := int64(0) + for _, b := range val { + if b >= '0' && b <= '9' { + n = n*10 + int64(b-'0') + } else { + break + } + } + return n + } + return 0 +} diff --git a/internal/services/security.go b/internal/services/security.go new file mode 100644 index 0000000..fc40f90 --- /dev/null +++ b/internal/services/security.go @@ -0,0 +1,741 @@ +package services + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +var ( + IPQSKey string + AbuseIPDBKey string + + disposableDomains map[string]bool + disposableDomainsMu sync.RWMutex + disposableDomainsTS time.Time + domainsCacheTTL = 1 * time.Hour + + localDisposableDomains = map[string]bool{ + "ztzt.net": true, "besteya.com": true, + } + + disposableURL = "https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf" + + dnsblList = []string{ + "zen.spamhaus.org", "dnsbl.dronebl.org", "bl.spamcop.net", + "bogons.cymru.com", "cbl.abuseat.org", "dnsbl.sorbs.net", + "tor.dan.me.uk", "rbl.efnetrbl.org", "rbl.schulte.org", + "dnsbl-1.uceprotect.net", + } + + dnsblCache = make(map[string]dnsblCacheEntry) + dnsblCacheMu sync.RWMutex + dnsblCacheTTL = 10 * time.Minute + + vpnCache = make(map[string]vpnCacheEntry) + vpnCacheMu sync.RWMutex + vpnCacheDur = 15 * time.Minute + + asnCache = make(map[string]asnCacheEntry) + asnCacheMu sync.RWMutex + + behaviorScores = make(map[string]*behaviorEntry) + behaviorMu sync.RWMutex + behaviorTTL = 1 * time.Hour + + suspiciousIPs = make(map[string]suspiciousEntry) + suspiciousMu sync.RWMutex + suspiciousTTL = 24 * time.Hour + + botUAPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)curl/`), regexp.MustCompile(`(?i)wget/`), + regexp.MustCompile(`(?i)node-fetch`), regexp.MustCompile(`(?i)python-requests`), + regexp.MustCompile(`(?i)python-httpx`), regexp.MustCompile(`(?i)urllib`), + regexp.MustCompile(`(?i)aiohttp`), regexp.MustCompile(`(?i)go-http-client`), + regexp.MustCompile(`(?i)java/\d+`), regexp.MustCompile(`(?i)libcurl`), + regexp.MustCompile(`(?i)okhttp`), regexp.MustCompile(`(?i)httpie`), + regexp.MustCompile(`(?i)postmanruntime`), regexp.MustCompile(`(?i)insomnia`), + regexp.MustCompile(`(?i)axios/`), regexp.MustCompile(`(?i)scrapy`), + regexp.MustCompile(`(?i)python-urllib`), regexp.MustCompile(`(?i)robot`), + regexp.MustCompile(`(?i)spider`), regexp.MustCompile(`(?i)crawler`), + regexp.MustCompile(`(?i)masscan`), regexp.MustCompile(`(?i)nmap`), + regexp.MustCompile(`(?i)zgrab`), regexp.MustCompile(`(?i)fscan`), + regexp.MustCompile(`(?i)fasthttp`), regexp.MustCompile(`(?i)selenium`), + regexp.MustCompile(`(?i)puppeteer`), regexp.MustCompile(`(?i)playwright`), + regexp.MustCompile(`(?i)cypress`), regexp.MustCompile(`(?i)headless`), + regexp.MustCompile(`(?i)phantomjs`), regexp.MustCompile(`(?i)pure-native`), + regexp.MustCompile(`(?i)datadog`), regexp.MustCompile(`(?i)newrelic`), + regexp.MustCompile(`(?i)restsharp`), + } + + cloudProviderASNs = map[string]bool{ + "AS16509": true, "AS39111": true, "AS45102": true, "AS16276": true, + "AS36351": true, "AS13335": true, "AS14618": true, "AS20115": true, + "AS8987": true, "AS26496": true, "AS30083": true, "AS40065": true, + "AS46690": true, "AS29791": true, "AS36492": true, "AS55095": true, + "AS55059": true, "AS13876": true, "AS20326": true, "AS21342": true, + "AS22385": true, "AS36352": true, "AS20473": true, "AS62567": true, + "AS32780": true, "AS394906": true, "AS54203": true, "AS53363": true, + "AS11878": true, "AS14061": true, "AS46664": true, "AS147008": true, + "AS199524": true, "AS396982": true, "AS63949": true, "AS60068": true, + "AS55286": true, "AS20454": true, "AS53869": true, "AS19551": true, + "AS8455": true, "AS29073": true, "AS16302": true, "AS21277": true, + "AS49333": true, "AS58057": true, "AS59441": true, "AS206264": true, + "AS61138": true, + } + + countryBlocklist = map[string]bool{ + "CN": true, "RU": true, "KP": true, "IR": true, "SY": true, "CU": true, "VE": true, + } + + honeypotFields = []string{"website", "url", "homepage", "message2", "confirm_email", "fax", "phone2"} + + suspiciousPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)]`), regexp.MustCompile(`(?i)javascript:`), + regexp.MustCompile(`(?i)onerror\s*=`), regexp.MustCompile(`(?i)onload\s*=`), + regexp.MustCompile(`(?i)onclick\s*=`), regexp.MustCompile(`(?i)onmouseover\s*=`), + regexp.MustCompile(`(?i)vbscript:`), regexp.MustCompile(`(?i)data:\s*text/html`), + regexp.MustCompile(`(?i)<\s*iframe`), regexp.MustCompile(`(?i)<\s*embed`), + regexp.MustCompile(`(?i)<\s*object`), regexp.MustCompile(`(?i)alert\s*\(`), + regexp.MustCompile(`(?i)prompt\s*\(`), regexp.MustCompile(`(?i)confirm\s*\(`), + regexp.MustCompile(`(?i)document\.cookie`), regexp.MustCompile(`(?i)window\.location`), + regexp.MustCompile(`(?i)base64,`), regexp.MustCompile(`(?i)fromCharCode`), + } + + querySuspicious = []string{"debug", "test", "bypass", "admin", "sudo", "cmd", "exec", + "command", "eval", "system", "shell", "sql", "union", "select", "from", "where", + "drop", "alter", "create", "insert", "delete", "update", "../", "..\\", "%00", + " dnsblCacheTTL { + delete(dnsblCache, k) + } + } + dnsblCacheMu.Unlock() + + vpnCacheMu.Lock() + for k, v := range vpnCache { + if now.Sub(v.timestamp) > vpnCacheDur { + delete(vpnCache, k) + } + } + vpnCacheMu.Unlock() + + asnCacheMu.Lock() + for k, v := range asnCache { + if now.Sub(v.timestamp) > vpnCacheDur { + delete(asnCache, k) + } + } + asnCacheMu.Unlock() + + behaviorMu.Lock() + for k, v := range behaviorScores { + if now.Sub(v.lastSeen) > behaviorTTL { + delete(behaviorScores, k) + } + } + behaviorMu.Unlock() + + suspiciousMu.Lock() + for k, v := range suspiciousIPs { + if now.Sub(v.timestamp) > suspiciousTTL { + delete(suspiciousIPs, k) + } + } + suspiciousMu.Unlock() + } + }() +} + +func InitSecurity(ipqsKey, abuseipdbKey string) error { + IPQSKey = ipqsKey + AbuseIPDBKey = abuseipdbKey + StartSecurityCleanup() + return nil +} + +func GetClientIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + parts := strings.Split(fwd, ",") + return strings.TrimSpace(parts[0]) + } + if ip := r.RemoteAddr; ip != "" { + if h, _, err := net.SplitHostPort(ip); err == nil { + return h + } + return ip + } + return "0.0.0.0" +} + +func NormalizeIP(ip string) string { + ip = strings.TrimSpace(ip) + if ip == "" { + return "" + } + ip = strings.TrimPrefix(ip, "::ffff:") + return ip +} + +func IsPrivateIP(ip string) bool { + clean := NormalizeIP(ip) + if clean == "" { + return false + } + parsed := net.ParseIP(clean) + if parsed == nil { + return false + } + if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() { + return true + } + if strings.HasPrefix(clean, "169.254.") { + return true + } + return false +} + +func IsBotUserAgent(ua string) bool { + if ua == "" || len(ua) < 10 { + return true + } + if ua == "Mozilla/5.0" || ua == "Mozilla/4.0" { + return true + } + for _, pattern := range botUAPatterns { + if pattern.MatchString(ua) { + return true + } + } + return false +} + +func IsKnownBotIP(ip string) bool { + clean := NormalizeIP(ip) + if clean == "" { + return false + } + knownBlocks := []string{"45.8.", "45.14.", "45.15.", "45.33.", "45.40.", "45.62.", + "45.64.", "45.79.", "45.80.", "45.83.", "45.88.", "45.91.", "45.94.", "45.128.", + "45.135.", "45.143.", "45.148.", "45.150.", "45.152.", "45.153.", "45.155."} + for _, block := range knownBlocks { + if strings.HasPrefix(clean, block) { + return true + } + } + return false +} + +func IsIpSuspicious(ip string) bool { + clean := NormalizeIP(ip) + if clean == "" { + return false + } + suspiciousMu.RLock() + if _, ok := suspiciousIPs[clean]; ok { + suspiciousMu.RUnlock() + return true + } + suspiciousMu.RUnlock() + + behaviorMu.RLock() + entry, ok := behaviorScores[clean] + behaviorMu.RUnlock() + if ok && entry.score >= 80 { + return true + } + return false +} + +func RecordFailedAction(ip, actionType string) { + clean := NormalizeIP(ip) + if clean == "" { + return + } + behaviorMu.Lock() + entry, ok := behaviorScores[clean] + if !ok { + entry = &behaviorEntry{firstSeen: time.Now()} + behaviorScores[clean] = entry + } + entry.lastSeen = time.Now() + entry.score += 15 + if entry.score > 100 { + entry.score = 100 + } + entry.failedActions++ + if actionType == "login" { + entry.failedLogins++ + } + if actionType == "register" { + entry.failedRegistrations++ + } + behaviorMu.Unlock() + + if entry.score >= 80 { + suspiciousMu.Lock() + suspiciousIPs[clean] = suspiciousEntry{timestamp: time.Now(), reason: "high_failure_rate"} + suspiciousMu.Unlock() + } +} + +func RecordSuccessfulAction(ip string) { + clean := NormalizeIP(ip) + if clean == "" { + return + } + behaviorMu.Lock() + if entry, ok := behaviorScores[clean]; ok { + entry.score -= 5 + if entry.score < 0 { + entry.score = 0 + } + } + behaviorMu.Unlock() +} + +func IsDisposableEmail(email string) bool { + if email == "" || !strings.Contains(email, "@") { + return false + } + parts := strings.Split(email, "@") + if len(parts) != 2 { + return false + } + domain := strings.ToLower(strings.TrimSpace(parts[1])) + + disposableDomainsMu.RLock() + cached := disposableDomains + ts := disposableDomainsTS + disposableDomainsMu.RUnlock() + + if cached == nil || time.Since(ts) > domainsCacheTTL { + loadDisposableDomains() + disposableDomainsMu.RLock() + cached = disposableDomains + disposableDomainsMu.RUnlock() + } + + if cached[domain] { + return true + } + for d := range cached { + if strings.HasSuffix(domain, "."+d) { + return true + } + } + return false +} + +func loadDisposableDomains() { + merged := make(map[string]bool) + for d := range localDisposableDomains { + merged[d] = true + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(disposableURL) + if err == nil { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + for _, line := range strings.Split(string(body), "\n") { + domain := strings.ToLower(strings.TrimSpace(line)) + if domain != "" && !strings.HasPrefix(domain, "#") { + merged[domain] = true + } + } + } + + disposableDomainsMu.Lock() + disposableDomains = merged + disposableDomainsTS = time.Now() + disposableDomainsMu.Unlock() +} + +func CheckPasswordBreach(password string) (map[string]interface{}, error) { + if len(password) < 6 { + return map[string]interface{}{"breached": false}, nil + } + h := sha256.Sum256([]byte(password)) + hexHash := strings.ToUpper(hex.EncodeToString(h[:])) + prefix := hexHash[:5] + suffix := hexHash[5:] + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.pwnedpasswords.com/range/" + prefix) + if err != nil { + return map[string]interface{}{"breached": false}, nil + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + lines := strings.Split(string(body), "\n") + for _, line := range lines { + parts := strings.Split(strings.TrimSpace(line), ":") + if len(parts) > 0 && parts[0] == suffix { + return map[string]interface{}{"breached": true}, nil + } + } + return map[string]interface{}{"breached": false}, nil +} + +func CheckHeaders(r *http.Request) []string { + var issues []string + if r.Header.Get("Accept") == "" { + issues = append(issues, "missing_accept") + } + if r.Header.Get("Accept-Language") == "" { + issues = append(issues, "missing_accept_language") + } + ua := strings.ToLower(r.Header.Get("User-Agent")) + if ua == "" { + issues = append(issues, "missing_ua") + } else if len(ua) < 20 { + issues = append(issues, "short_ua") + } + if ua == "mozilla/5.0" { + issues = append(issues, "generic_ua") + } + if r.Header.Get("Sec-CH-UA") == "" && r.Header.Get("Sec-CH-UA-Mobile") == "" { + if !strings.Contains(ua, "headless") && !strings.Contains(ua, "bot") { + issues = append(issues, "missing_sec_ch_ua") + } + } + return issues +} + +func ValidateBrowserSignature(r *http.Request) map[string]interface{} { + total := 0 + var checks []string + + accept := r.Header.Get("Accept") + if matched, _ := regexp.MatchString(`text/html|application/json|\*/\*`, accept); matched { + total += 10 + checks = append(checks, "accept_pass") + } else { + checks = append(checks, "accept_suspicious") + } + + lang := r.Header.Get("Accept-Language") + if matched, _ := regexp.MatchString(`^[a-z]{2}(-[A-Z]{2})?(,[a-z]{2}(-[A-Z]{2})?)*$`, lang); matched { + total += 10 + checks = append(checks, "lang_pass") + } else { + checks = append(checks, "lang_suspicious") + } + + for _, header := range []string{"Sec-Fetch-Site", "Sec-Fetch-Mode", "Sec-Fetch-Dest"} { + val := r.Header.Get(header) + if val != "" { + total += 15 + checks = append(checks, strings.ToLower(header)+"_pass") + } + } + + if r.Header.Get("Sec-CH-UA") != "" { + total += 15 + checks = append(checks, "sec_ch_ua_pass") + } + + if r.Header.Get("DNT") != "" || r.Header.Get("Sec-GPC") != "" { + total += 5 + checks = append(checks, "dnt_pass") + } + + ua := strings.ToLower(r.Header.Get("User-Agent")) + if strings.Contains(ua, "windows") || strings.Contains(ua, "mac") || strings.Contains(ua, "linux") || strings.Contains(ua, "android") || strings.Contains(ua, "ios") || strings.Contains(ua, "iphone") || strings.Contains(ua, "like mac") { + total += 15 + checks = append(checks, "os_pass") + } + + return map[string]interface{}{ + "total": total, + "checks": checks, + "passed": total >= 60, + } +} + +func CheckBodySuspicious(body interface{}) map[string]interface{} { + if body == nil { + return map[string]interface{}{"flagged": false} + } + str, ok := body.(string) + if !ok { + b, err := json.Marshal(body) + if err != nil { + return map[string]interface{}{"flagged": false} + } + str = string(b) + } + for _, pattern := range suspiciousPatterns { + if pattern.MatchString(str) { + return map[string]interface{}{"flagged": true, "pattern": pattern.String()} + } + } + return map[string]interface{}{"flagged": false} +} + +func CheckReferrer(r *http.Request) map[string]interface{} { + referer := r.Header.Get("Referer") + if referer == "" { + return map[string]interface{}{"passed": false, "reason": "missing_referrer"} + } + validHosts := []string{"dashboard.zero-host.org", "zero-host.org", "localhost:3000", "127.0.0.1:3000"} + for _, h := range validHosts { + if strings.Contains(referer, h) { + return map[string]interface{}{"passed": true, "host": referer} + } + } + return map[string]interface{}{"passed": false, "reason": "invalid_referrer"} +} + +func CheckHoneypot(body map[string]interface{}) map[string]interface{} { + if body == nil { + return map[string]interface{}{"triggered": false} + } + for _, field := range honeypotFields { + if val, ok := body[field]; ok && val != "" && val != nil { + return map[string]interface{}{"triggered": true, "field": field} + } + } + return map[string]interface{}{"triggered": false} +} + +func CheckSuspiciousQueryParams(r *http.Request) map[string]interface{} { + query := r.URL.Query() + for key := range query { + val := strings.ToLower(query.Get(key)) + for _, s := range querySuspicious { + if strings.Contains(val, s) { + return map[string]interface{}{"flagged": true, "param": key, "pattern": s} + } + } + } + return map[string]interface{}{"flagged": false} +} + +func DetectVPNProxy(ip string) map[string]interface{} { + clean := NormalizeIP(ip) + if clean == "" || IsPrivateIP(clean) { + return map[string]interface{}{"isVpn": false, "isProxy": false, "source": "private"} + } + + vpnCacheMu.RLock() + if cached, ok := vpnCache[clean]; ok && time.Since(cached.timestamp) < vpnCacheDur { + vpnCacheMu.RUnlock() + return cached.result + } + vpnCacheMu.RUnlock() + + result := map[string]interface{}{"isVpn": false, "isProxy": false, "source": "none"} + var asn string + + client := &http.Client{Timeout: 5 * time.Second} + + // Try ip-api.com + if resp, err := client.Get(fmt.Sprintf("http://ip-api.com/json/%s?fields=proxy,hosting,isp,org,as,query", clean)); err == nil { + var data map[string]interface{} + json.NewDecoder(resp.Body).Decode(&data) + resp.Body.Close() + if proxy, _ := data["proxy"].(bool); proxy { + result = map[string]interface{}{"isVpn": true, "isProxy": true, "source": "ip-api"} + } + if hosting, _ := data["hosting"].(bool); hosting { + result = map[string]interface{}{"isVpn": true, "isProxy": true, "source": "ip-api"} + } + if a, ok := data["as"].(string); ok { + asn = a + if cloudProviderASNs[asn] { + result = map[string]interface{}{"isVpn": true, "isProxy": true, "source": "asn", "asn": asn} + } + } + } + + // Try ipinfo.io + if isVpn, _ := result["isVpn"].(bool); !isVpn { + if resp, err := client.Get(fmt.Sprintf("https://ipinfo.io/%s/json", clean)); err == nil { + var data map[string]interface{} + json.NewDecoder(resp.Body).Decode(&data) + resp.Body.Close() + if org, ok := data["org"].(string); ok { + orgLower := strings.ToLower(org) + if strings.Contains(orgLower, "vpn") || strings.Contains(orgLower, "proxy") || strings.Contains(orgLower, "tor") || strings.Contains(orgLower, "datacenter") || strings.Contains(orgLower, "cloud") || strings.Contains(orgLower, "hosting") { + result = map[string]interface{}{"isVpn": true, "isProxy": true, "source": "ipinfo"} + } + } + if asn == "" { + if asnStr, ok := data["asn"].(string); ok { + parts := strings.Split(asnStr, " ") + if len(parts) > 0 && cloudProviderASNs[parts[0]] { + result = map[string]interface{}{"isVpn": true, "isProxy": true, "source": "asn", "asn": parts[0]} + } + } + } + } + } + + vpnCacheMu.Lock() + vpnCache[clean] = vpnCacheEntry{result: result, timestamp: time.Now()} + vpnCacheMu.Unlock() + + return result +} + +func CheckBlockedCountry(ip string) map[string]interface{} { + clean := NormalizeIP(ip) + if clean == "" || IsPrivateIP(clean) { + return map[string]interface{}{"blocked": false} + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://ip-api.com/json/%s?fields=countryCode", clean)) + if err != nil { + return map[string]interface{}{"blocked": false} + } + defer resp.Body.Close() + + var data map[string]interface{} + json.NewDecoder(resp.Body).Decode(&data) + cc, _ := data["countryCode"].(string) + return map[string]interface{}{"blocked": countryBlocklist[cc], "countryCode": cc} +} + +func CalculateOverallRisk(r *http.Request) map[string]interface{} { + risk := 0 + var reasons []string + ip := GetClientIP(r) + ua := r.Header.Get("User-Agent") + + if IsBotUserAgent(ua) { + risk += 30 + reasons = append(reasons, "bot_ua") + } + + issues := CheckHeaders(r) + if len(issues) >= 2 { + risk += 15 + reasons = append(reasons, "bad_headers") + } + if len(issues) >= 4 { + risk += 10 + reasons = append(reasons, "very_bad_headers") + } + + clean := NormalizeIP(ip) + if clean != "" && IsPrivateIP(clean) { + risk += 5 + reasons = append(reasons, "private_ip") + } + if clean != "" && IsKnownBotIP(clean) { + risk += 25 + reasons = append(reasons, "known_bot_ip") + } + if clean != "" && IsIpSuspicious(clean) { + risk += 20 + reasons = append(reasons, "suspicious_ip") + } + + accept := r.Header.Get("Accept") + if accept == "" || accept == "*/*" { + risk += 10 + reasons = append(reasons, "generic_accept") + } + if r.Header.Get("Accept-Encoding") == "" { + risk += 5 + reasons = append(reasons, "no_encoding") + } + + level := "low" + if risk >= 60 { + level = "high" + } else if risk >= 30 { + level = "medium" + } + + return map[string]interface{}{ + "risk": risk, + "reasons": reasons, + "level": level, + } +} + +func CheckConcurrentRequests(ip string, maxConcurrent int) map[string]interface{} { + return map[string]interface{}{"allowed": true} +} + +func GenerateSubmitToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +func HashRecoveryCode(code string) string { + h := sha256.Sum256([]byte(code)) + return hex.EncodeToString(h[:]) +} + +func GenerateRecoveryCodes(count int) []string { + codes := make([]string, count) + for i := 0; i < count; i++ { + b := make([]byte, 4) + rand.Read(b) + h := hex.EncodeToString(b) + codes[i] = strings.ToUpper(h[:4] + "-" + h[4:]) + } + return codes +} + +func HMACSHA256(message, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) +}