update

Changed files
+352 -43
internal
+44 -1
internal/api/handlers.go
··· 11 11 "os" 12 12 "path/filepath" 13 13 "strconv" 14 + "strings" 14 15 "time" 15 16 16 17 "github.com/atscan/atscanner/internal/log" ··· 94 95 } 95 96 96 97 func formatEndpointResponse(ep *storage.Endpoint) map[string]interface{} { 97 - return map[string]interface{}{ 98 + response := map[string]interface{}{ 98 99 "id": ep.ID, 99 100 "endpoint_type": ep.EndpointType, 100 101 "endpoint": ep.Endpoint, ··· 103 104 "status": statusToString(ep.Status), 104 105 "user_count": ep.UserCount, 105 106 } 107 + 108 + // Add IP if available 109 + if ep.IP != "" { 110 + response["ip"] = ep.IP 111 + } 112 + 113 + // Extract specific fields from ip_info 114 + if ep.IPInfo != nil { 115 + // Extract location info 116 + if location, ok := ep.IPInfo["location"].(map[string]interface{}); ok { 117 + if city, ok := location["city"].(string); ok { 118 + response["city"] = city 119 + } 120 + if countryCode, ok := location["country_code"].(string); ok { 121 + response["country_code"] = countryCode 122 + } 123 + if country, ok := location["country"].(string); ok { 124 + response["country"] = country 125 + } 126 + } 127 + 128 + // Extract ASN info 129 + if asn, ok := ep.IPInfo["asn"].(map[string]interface{}); ok { 130 + if asnNumber, ok := asn["asn"].(float64); ok { 131 + response["asn"] = int(asnNumber) 132 + } 133 + } 134 + 135 + // Optionally include full ip_info for detailed view 136 + // response["ip_info"] = ep.IPInfo 137 + } 138 + 139 + return response 106 140 } 107 141 108 142 func statusToString(status int) string { ··· 152 186 endpointType = "pds" 153 187 } 154 188 189 + endpoint = "https://" + normalizeEndpoint(endpoint) 155 190 ep, err := s.db.GetEndpoint(r.Context(), endpoint, endpointType) 156 191 if err != nil { 157 192 resp.error("Endpoint not found", http.StatusNotFound) ··· 163 198 result := formatEndpointResponse(ep) 164 199 result["recent_scans"] = scans 165 200 201 + result["ipinfo"] = ep.IPInfo 166 202 resp.json(result) 167 203 } 168 204 ··· 1101 1137 hash := sha256.Sum256(jsonlData) 1102 1138 return hex.EncodeToString(hash[:]) 1103 1139 } 1140 + 1141 + func normalizeEndpoint(endpoint string) string { 1142 + endpoint = strings.TrimPrefix(endpoint, "https://") 1143 + endpoint = strings.TrimPrefix(endpoint, "http://") 1144 + endpoint = strings.TrimSuffix(endpoint, "/") 1145 + return endpoint 1146 + }
+139
internal/ipinfo/client.go
··· 1 + package ipinfo 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net" 8 + "net/http" 9 + "net/url" 10 + "sync" 11 + "time" 12 + ) 13 + 14 + type Client struct { 15 + httpClient *http.Client 16 + baseURL string 17 + mu sync.RWMutex 18 + backoffUntil time.Time 19 + backoffDuration time.Duration 20 + } 21 + 22 + func NewClient() *Client { 23 + return &Client{ 24 + httpClient: &http.Client{ 25 + Timeout: 10 * time.Second, 26 + }, 27 + baseURL: "https://api.ipapi.is", 28 + backoffDuration: 5 * time.Minute, 29 + } 30 + } 31 + 32 + // IsInBackoff checks if we're currently in backoff period 33 + func (c *Client) IsInBackoff() bool { 34 + c.mu.RLock() 35 + defer c.mu.RUnlock() 36 + return time.Now().Before(c.backoffUntil) 37 + } 38 + 39 + // SetBackoff sets the backoff period 40 + func (c *Client) SetBackoff() { 41 + c.mu.Lock() 42 + defer c.mu.Unlock() 43 + c.backoffUntil = time.Now().Add(c.backoffDuration) 44 + } 45 + 46 + // ClearBackoff clears the backoff (on successful request) 47 + func (c *Client) ClearBackoff() { 48 + c.mu.Lock() 49 + defer c.mu.Unlock() 50 + c.backoffUntil = time.Time{} 51 + } 52 + 53 + // GetIPInfo fetches IP information from ipapi.is 54 + func (c *Client) GetIPInfo(ctx context.Context, ip string) (map[string]interface{}, error) { 55 + // Check if we're in backoff period 56 + if c.IsInBackoff() { 57 + c.mu.RLock() 58 + remaining := time.Until(c.backoffUntil) 59 + c.mu.RUnlock() 60 + return nil, fmt.Errorf("in backoff period, retry in %v", remaining.Round(time.Second)) 61 + } 62 + 63 + // Build URL with IP parameter 64 + reqURL := fmt.Sprintf("%s/?q=%s", c.baseURL, url.QueryEscape(ip)) 65 + 66 + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 67 + if err != nil { 68 + return nil, fmt.Errorf("failed to create request: %w", err) 69 + } 70 + 71 + resp, err := c.httpClient.Do(req) 72 + if err != nil { 73 + // Set backoff on network errors (timeout, etc) 74 + c.SetBackoff() 75 + return nil, fmt.Errorf("failed to fetch IP info: %w", err) 76 + } 77 + defer resp.Body.Close() 78 + 79 + if resp.StatusCode == http.StatusTooManyRequests { 80 + // Set backoff on rate limit 81 + c.SetBackoff() 82 + return nil, fmt.Errorf("rate limited (429), backing off for %v", c.backoffDuration) 83 + } 84 + 85 + if resp.StatusCode != http.StatusOK { 86 + // Set backoff on other errors too 87 + c.SetBackoff() 88 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 89 + } 90 + 91 + var ipInfo map[string]interface{} 92 + if err := json.NewDecoder(resp.Body).Decode(&ipInfo); err != nil { 93 + return nil, fmt.Errorf("failed to decode response: %w", err) 94 + } 95 + 96 + // Clear backoff on successful request 97 + c.ClearBackoff() 98 + 99 + return ipInfo, nil 100 + } 101 + 102 + // ExtractIPFromEndpoint extracts IP from endpoint URL 103 + func ExtractIPFromEndpoint(endpoint string) (string, error) { 104 + // Parse URL 105 + parsedURL, err := url.Parse(endpoint) 106 + if err != nil { 107 + return "", fmt.Errorf("failed to parse endpoint URL: %w", err) 108 + } 109 + 110 + host := parsedURL.Hostname() 111 + if host == "" { 112 + return "", fmt.Errorf("no hostname in endpoint") 113 + } 114 + 115 + // Check if host is already an IP 116 + if net.ParseIP(host) != nil { 117 + return host, nil 118 + } 119 + 120 + // Resolve hostname to IP 121 + ips, err := net.LookupIP(host) 122 + if err != nil { 123 + return "", fmt.Errorf("failed to resolve hostname: %w", err) 124 + } 125 + 126 + if len(ips) == 0 { 127 + return "", fmt.Errorf("no IPs found for hostname") 128 + } 129 + 130 + // Return first IPv4 address 131 + for _, ip := range ips { 132 + if ipv4 := ip.To4(); ipv4 != nil { 133 + return ipv4.String(), nil 134 + } 135 + } 136 + 137 + // Fallback to first IP (might be IPv6) 138 + return ips[0].String(), nil 139 + }
+62 -10
internal/pds/scanner.go
··· 7 7 8 8 "github.com/acarl005/stripansi" 9 9 "github.com/atscan/atscanner/internal/config" 10 + "github.com/atscan/atscanner/internal/ipinfo" 10 11 "github.com/atscan/atscanner/internal/log" 11 12 "github.com/atscan/atscanner/internal/storage" 12 13 ) 13 14 14 15 type Scanner struct { 15 - client *Client 16 - db storage.Database 17 - config config.PDSConfig 16 + client *Client 17 + db storage.Database 18 + config config.PDSConfig 19 + ipInfoClient *ipinfo.Client 18 20 } 19 21 20 22 func NewScanner(db storage.Database, cfg config.PDSConfig) *Scanner { 21 23 return &Scanner{ 22 - client: NewClient(cfg.Timeout), 23 - db: db, 24 - config: cfg, 24 + client: NewClient(cfg.Timeout), 25 + db: db, 26 + config: cfg, 27 + ipInfoClient: ipinfo.NewClient(), 25 28 } 26 29 } 27 30 ··· 121 124 122 125 func (s *Scanner) scanPDS(ctx context.Context, endpointID int64, endpoint string) *PDSStatus { 123 126 status := &PDSStatus{ 124 - EndpointID: endpointID, // Store Endpoint ID 127 + EndpointID: endpointID, 125 128 Endpoint: endpoint, 126 - LastChecked: time.Now(), 129 + LastChecked: time.Now().UTC(), 127 130 } 128 131 129 132 // Health check ··· 141 144 return status 142 145 } 143 146 147 + // Fetch and update IP info if needed 148 + s.updateIPInfoIfNeeded(ctx, endpointID, endpoint) 149 + 144 150 // Describe server 145 151 desc, err := s.client.DescribeServer(ctx, endpoint) 146 152 if err != nil { ··· 150 156 } 151 157 152 158 // Optionally list repos (DIDs) - commented out by default for performance 153 - /*dids, err := s.client.ListRepos(ctx, endpoint) 159 + dids, err := s.client.ListRepos(ctx, endpoint) 154 160 if err != nil { 155 161 log.Verbose("Warning: failed to list repos for %s: %v", endpoint, err) 156 162 status.DIDs = []string{} 157 163 } else { 158 164 status.DIDs = dids 159 165 log.Verbose(" → Found %d users on %s", len(dids), endpoint) 160 - }*/ 166 + } 161 167 162 168 return status 163 169 } 170 + 171 + func (s *Scanner) updateIPInfoIfNeeded(ctx context.Context, endpointID int64, endpoint string) { 172 + // Check if IP info client is in backoff 173 + if s.ipInfoClient.IsInBackoff() { 174 + // Silently skip during backoff period 175 + return 176 + } 177 + 178 + // Extract IP from endpoint 179 + ip, err := ipinfo.ExtractIPFromEndpoint(endpoint) 180 + if err != nil { 181 + log.Verbose("Failed to extract IP from %s: %v", endpoint, err) 182 + return 183 + } 184 + 185 + // Check if we need to update IP info 186 + shouldUpdate, err := s.db.ShouldUpdateIPInfo(ctx, endpointID, ip) 187 + if err != nil { 188 + log.Verbose("Failed to check IP info status: %v", err) 189 + return 190 + } 191 + 192 + if !shouldUpdate { 193 + return // IP hasn't changed, no need to fetch 194 + } 195 + 196 + // Fetch IP info from ipapi.is 197 + log.Verbose("Fetching IP info for %s (%s)", endpoint, ip) 198 + ipInfo, err := s.ipInfoClient.GetIPInfo(ctx, ip) 199 + if err != nil { 200 + // Log only once when backoff starts 201 + if s.ipInfoClient.IsInBackoff() { 202 + log.Info("⚠ IP info API unavailable, pausing requests for 5 minutes") 203 + } else { 204 + log.Verbose("Failed to fetch IP info for %s: %v", ip, err) 205 + } 206 + return 207 + } 208 + 209 + // Update database 210 + if err := s.db.UpdateEndpointIPInfo(ctx, endpointID, ip, ipInfo); err != nil { 211 + log.Error("Failed to update IP info for endpoint %d: %v", endpointID, err) 212 + } else { 213 + log.Verbose("✓ Updated IP info for %s: %s", endpoint, ip) 214 + } 215 + }
+4 -1
internal/storage/db.go
··· 23 23 // Endpoint operations 24 24 UpsertEndpoint(ctx context.Context, endpoint *Endpoint) error 25 25 GetEndpoint(ctx context.Context, endpoint string, endpointType string) (*Endpoint, error) 26 - GetEndpointByID(ctx context.Context, id int64) (*Endpoint, error) 27 26 GetEndpoints(ctx context.Context, filter *EndpointFilter) ([]*Endpoint, error) 28 27 UpdateEndpointStatus(ctx context.Context, endpointID int64, update *EndpointUpdate) error 29 28 EndpointExists(ctx context.Context, endpoint string, endpointType string) (bool, error) 30 29 GetEndpointIDByEndpoint(ctx context.Context, endpoint string, endpointType string) (int64, error) 31 30 GetEndpointScans(ctx context.Context, endpointID int64, limit int) ([]*EndpointScan, error) 31 + 32 + // IP info operations 33 + ShouldUpdateIPInfo(ctx context.Context, endpointID int64, currentIP string) (bool, error) 34 + UpdateEndpointIPInfo(ctx context.Context, endpointID int64, ip string, ipInfo map[string]interface{}) error 32 35 33 36 // Cursor operations 34 37 GetScanCursor(ctx context.Context, source string) (*ScanCursor, error)
+101 -31
internal/storage/postgres.go
··· 63 63 last_checked TIMESTAMP, 64 64 status INTEGER DEFAULT 0, 65 65 user_count BIGINT DEFAULT 0, 66 + ip TEXT, 67 + ip_info JSONB, 66 68 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 67 69 UNIQUE(endpoint_type, endpoint) 68 70 ); ··· 71 73 CREATE INDEX IF NOT EXISTS idx_endpoints_status ON endpoints(status); 72 74 CREATE INDEX IF NOT EXISTS idx_endpoints_type ON endpoints(endpoint_type); 73 75 CREATE INDEX IF NOT EXISTS idx_endpoints_user_count ON endpoints(user_count); 76 + CREATE INDEX IF NOT EXISTS idx_endpoints_ip ON endpoints(ip); 77 + CREATE INDEX IF NOT EXISTS idx_endpoints_ip_info ON endpoints USING gin(ip_info); 74 78 75 79 CREATE TABLE IF NOT EXISTS pds_scans ( 76 80 id BIGSERIAL PRIMARY KEY, ··· 542 546 // ===== ENDPOINT OPERATIONS ===== 543 547 544 548 func (p *PostgresDB) UpsertEndpoint(ctx context.Context, endpoint *Endpoint) error { 549 + var ipInfoJSON []byte 550 + var err error 551 + if endpoint.IPInfo != nil { 552 + ipInfoJSON, err = json.Marshal(endpoint.IPInfo) 553 + if err != nil { 554 + return fmt.Errorf("failed to marshal ip_info: %w", err) 555 + } 556 + } 557 + 545 558 query := ` 546 - INSERT INTO endpoints (endpoint_type, endpoint, discovered_at, last_checked, status) 547 - VALUES ($1, $2, $3, $4, $5) 559 + INSERT INTO endpoints (endpoint_type, endpoint, discovered_at, last_checked, status, ip, ip_info) 560 + VALUES ($1, $2, $3, $4, $5, $6, $7) 548 561 ON CONFLICT(endpoint_type, endpoint) DO UPDATE SET 549 - last_checked = EXCLUDED.last_checked 562 + last_checked = EXCLUDED.last_checked, 563 + ip = CASE 564 + WHEN EXCLUDED.ip IS NOT NULL AND EXCLUDED.ip != '' THEN EXCLUDED.ip 565 + ELSE endpoints.ip 566 + END, 567 + ip_info = CASE 568 + WHEN EXCLUDED.ip_info IS NOT NULL THEN EXCLUDED.ip_info 569 + ELSE endpoints.ip_info 570 + END 550 571 RETURNING id 551 572 ` 552 - err := p.db.QueryRowContext(ctx, query, 573 + err = p.db.QueryRowContext(ctx, query, 553 574 endpoint.EndpointType, endpoint.Endpoint, endpoint.DiscoveredAt, 554 - endpoint.LastChecked, endpoint.Status).Scan(&endpoint.ID) 575 + endpoint.LastChecked, endpoint.Status, endpoint.IP, ipInfoJSON).Scan(&endpoint.ID) 555 576 return err 556 577 } 557 578 ··· 571 592 572 593 func (p *PostgresDB) GetEndpoint(ctx context.Context, endpoint string, endpointType string) (*Endpoint, error) { 573 594 query := ` 574 - SELECT id, endpoint_type, endpoint, discovered_at, last_checked, status, user_count, updated_at 595 + SELECT id, endpoint_type, endpoint, discovered_at, last_checked, status, user_count, 596 + ip, ip_info, updated_at 575 597 FROM endpoints 576 598 WHERE endpoint = $1 AND endpoint_type = $2 577 599 ` 578 600 579 601 var ep Endpoint 580 602 var lastChecked sql.NullTime 603 + var ip sql.NullString 604 + var ipInfoJSON []byte 581 605 582 606 err := p.db.QueryRowContext(ctx, query, endpoint, endpointType).Scan( 583 607 &ep.ID, &ep.EndpointType, &ep.Endpoint, &ep.DiscoveredAt, &lastChecked, 584 - &ep.Status, &ep.UserCount, &ep.UpdatedAt, 608 + &ep.Status, &ep.UserCount, &ip, &ipInfoJSON, &ep.UpdatedAt, 585 609 ) 586 610 if err != nil { 587 611 return nil, err ··· 591 615 ep.LastChecked = lastChecked.Time 592 616 } 593 617 594 - return &ep, nil 595 - } 596 - 597 - func (p *PostgresDB) GetEndpointByID(ctx context.Context, id int64) (*Endpoint, error) { 598 - query := ` 599 - SELECT id, endpoint_type, endpoint, discovered_at, last_checked, status, user_count, updated_at 600 - FROM endpoints 601 - WHERE id = $1 602 - ` 603 - 604 - var ep Endpoint 605 - var lastChecked sql.NullTime 606 - 607 - err := p.db.QueryRowContext(ctx, query, id).Scan( 608 - &ep.ID, &ep.EndpointType, &ep.Endpoint, &ep.DiscoveredAt, &lastChecked, 609 - &ep.Status, &ep.UserCount, &ep.UpdatedAt, 610 - ) 611 - if err != nil { 612 - return nil, err 618 + if ip.Valid { 619 + ep.IP = ip.String 613 620 } 614 621 615 - if lastChecked.Valid { 616 - ep.LastChecked = lastChecked.Time 622 + if len(ipInfoJSON) > 0 { 623 + var ipInfo map[string]interface{} 624 + if err := json.Unmarshal(ipInfoJSON, &ipInfo); err == nil { 625 + ep.IPInfo = ipInfo 626 + } 617 627 } 618 628 619 629 return &ep, nil ··· 621 631 622 632 func (p *PostgresDB) GetEndpoints(ctx context.Context, filter *EndpointFilter) ([]*Endpoint, error) { 623 633 query := ` 624 - SELECT id, endpoint_type, endpoint, discovered_at, last_checked, status, user_count, updated_at 634 + SELECT id, endpoint_type, endpoint, discovered_at, last_checked, status, user_count, 635 + ip, ip_info, updated_at 625 636 FROM endpoints 626 637 WHERE 1=1 627 638 ` ··· 670 681 for rows.Next() { 671 682 var ep Endpoint 672 683 var lastChecked sql.NullTime 684 + var ip sql.NullString 685 + var ipInfoJSON []byte 673 686 674 687 err := rows.Scan( 675 688 &ep.ID, &ep.EndpointType, &ep.Endpoint, &ep.DiscoveredAt, &lastChecked, 676 - &ep.Status, &ep.UserCount, &ep.UpdatedAt, 689 + &ep.Status, &ep.UserCount, &ip, &ipInfoJSON, &ep.UpdatedAt, 677 690 ) 678 691 if err != nil { 679 692 return nil, err ··· 681 694 682 695 if lastChecked.Valid { 683 696 ep.LastChecked = lastChecked.Time 697 + } 698 + 699 + if ip.Valid { 700 + ep.IP = ip.String 701 + } 702 + 703 + if len(ipInfoJSON) > 0 { 704 + var ipInfo map[string]interface{} 705 + if err := json.Unmarshal(ipInfoJSON, &ipInfo); err == nil { 706 + ep.IPInfo = ipInfo 707 + } 684 708 } 685 709 686 710 endpoints = append(endpoints, &ep) ··· 706 730 SET status = $1, last_checked = $2, user_count = $3, updated_at = $4 707 731 WHERE id = $5 708 732 ` 709 - _, err = tx.ExecContext(ctx, query, update.Status, update.LastChecked, userCount, time.Now(), endpointID) 733 + _, err = tx.ExecContext(ctx, query, update.Status, update.LastChecked, userCount, time.Now().UTC(), endpointID) 710 734 if err != nil { 711 735 return err 712 736 } ··· 725 749 return err 726 750 } 727 751 752 + // Keep only the 3 most recent scans per endpoint 753 + cleanupQuery := ` 754 + DELETE FROM pds_scans 755 + WHERE pds_id = $1 756 + AND id NOT IN ( 757 + SELECT id FROM pds_scans 758 + WHERE pds_id = $1 759 + ORDER BY scanned_at DESC 760 + LIMIT 3 761 + ) 762 + ` 763 + _, err = tx.ExecContext(ctx, cleanupQuery, endpointID) 764 + if err != nil { 765 + return err 766 + } 767 + 728 768 return tx.Commit() 729 769 } 730 770 ··· 814 854 } 815 855 816 856 return &stats, err 857 + } 858 + 859 + // Add method to check if IP needs update 860 + func (p *PostgresDB) ShouldUpdateIPInfo(ctx context.Context, endpointID int64, currentIP string) (bool, error) { 861 + query := `SELECT ip FROM endpoints WHERE id = $1` 862 + 863 + var storedIP sql.NullString 864 + err := p.db.QueryRowContext(ctx, query, endpointID).Scan(&storedIP) 865 + if err != nil { 866 + return false, err 867 + } 868 + 869 + // Update if no IP stored or IP changed 870 + return !storedIP.Valid || storedIP.String != currentIP, nil 871 + } 872 + 873 + // Update IP info for an endpoint 874 + func (p *PostgresDB) UpdateEndpointIPInfo(ctx context.Context, endpointID int64, ip string, ipInfo map[string]interface{}) error { 875 + ipInfoJSON, err := json.Marshal(ipInfo) 876 + if err != nil { 877 + return fmt.Errorf("failed to marshal ip_info: %w", err) 878 + } 879 + 880 + query := ` 881 + UPDATE endpoints 882 + SET ip = $1, ip_info = $2, updated_at = $3 883 + WHERE id = $4 884 + ` 885 + _, err = p.db.ExecContext(ctx, query, ip, ipInfoJSON, time.Now(), endpointID) 886 + return err 817 887 } 818 888 819 889 // ===== CURSOR OPERATIONS =====
+2
internal/storage/types.go
··· 24 24 LastChecked time.Time 25 25 Status int 26 26 UserCount int64 27 + IP string 28 + IPInfo map[string]interface{} 27 29 UpdatedAt time.Time 28 30 } 29 31