+44
-1
internal/api/handlers.go
+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
+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
+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
+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
+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 =====