2
2
import logging
3
3
import asyncio
4
4
from radixtarget .tree .ip import IPRadixTree
5
+ from cachetools import LRUCache
5
6
6
7
log = logging .getLogger ("bbot.core.helpers.asn" )
7
8
@@ -15,45 +16,86 @@ def __init__(self, parent_helper):
15
16
# IP radix trees (authoritative store) – IPv4 and IPv6
16
17
self ._tree4 : IPRadixTree = IPRadixTree ()
17
18
self ._tree6 : IPRadixTree = IPRadixTree ()
18
- self ._subnet_to_asn_cache : dict [str , list ] = {}
19
+ # LRU caches with reasonable limits to prevent unbounded memory growth
20
+ self ._subnet_to_asn_cache : LRUCache = LRUCache (maxsize = 10000 ) # Cache subnet -> ASN mappings
19
21
# ASN cache (ASN ID -> data mapping)
20
- self ._asn_to_data_cache : dict [ int , list ] = {}
22
+ self ._asn_to_data_cache : LRUCache = LRUCache ( maxsize = 5000 ) # Cache ASN records
21
23
22
24
# Default record used when no ASN data can be found
23
25
UNKNOWN_ASN = {
24
26
"asn" : "0" ,
25
27
"subnets" : [],
26
28
"name" : "unknown" ,
27
29
"description" : "unknown" ,
28
- "country" : "" ,
30
+ "country" : "unknown " ,
29
31
}
30
32
31
33
async def _request_with_retry (self , url , max_retries = 10 ):
34
+ log .critical (f"ASN API request: { url } " )
32
35
"""Make request with retry for 429 responses using Retry-After header."""
33
36
for attempt in range (max_retries + 1 ):
34
37
response = await self .parent_helper .request (url , timeout = 15 )
35
- if response is None or getattr (response , "status_code" , 0 ) != 429 :
38
+ if response is None or getattr (response , "status_code" , 0 ) == 200 :
36
39
log .debug (f"ASN API request successful, status code: { getattr (response , 'status_code' , 0 )} " )
37
40
return response
38
41
39
- if attempt < max_retries :
40
- log .debug (f"ASN API rate limited, attempt { attempt + 1 } " )
41
- # Get retry-after header value, default to 1 second if not present
42
- retry_after = getattr (response , "headers" , {}).get ("retry-after" , "1" )
43
- try :
44
- delay = int (retry_after ) + 1
45
- except (ValueError , TypeError ):
46
- delay = 2 # fallback if header is invalid
47
-
48
- log .debug (
49
- f"ASN API rate limited, waiting { delay } s (retry-after: { retry_after } ) (attempt { attempt + 1 } )"
50
- )
51
- await asyncio .sleep (delay )
42
+ elif getattr (response , "status_code" , 0 ) == 429 :
43
+ if attempt < max_retries :
44
+ attempt += 1
45
+ # Get retry-after header value, default to 1 second if not present
46
+ retry_after = getattr (response , "headers" , {}).get ("retry-after" , "10" )
47
+ delay = int (retry_after )
48
+ log .verbose (
49
+ f"ASN API rate limited, waiting { delay } s (retry-after: { retry_after } ) (attempt { attempt } )"
50
+ )
51
+ await asyncio .sleep (delay )
52
+ else :
53
+ log .warning (f"ASN API gave up after { max_retries + 1 } attempts due to repeatedrate limiting" )
54
+ elif getattr (response , "status_code" , 0 ) == 404 :
55
+ log .debug (f"ASN API returned 404 for { url } " )
56
+ return None
52
57
else :
53
- log .warning (f"ASN API gave up after { max_retries + 1 } attempts due to rate limiting" )
58
+ log .warning (
59
+ f"Got unexpected status code: { getattr (response , 'status_code' , 0 )} from ASN DB api ({ url } )"
60
+ )
61
+ return None
54
62
55
63
return response
56
64
65
+ async def _query_api (self , identifier , url_base , processor_method ):
66
+ """Common API query method that handles request/response pattern."""
67
+ url = f"{ url_base } { identifier } "
68
+ response = await self ._request_with_retry (url )
69
+ if response is None :
70
+ log .warning (f"ASN DB API: no response for { identifier } " )
71
+ return None
72
+
73
+ status = getattr (response , "status_code" , 0 )
74
+ if status != 200 :
75
+ return None
76
+
77
+ try :
78
+ raw = response .json ()
79
+ except Exception as e :
80
+ log .warning (f"ASN DB API: JSON decode error for { identifier } : { e } " )
81
+ return None
82
+
83
+ if isinstance (raw , dict ):
84
+ return processor_method (raw , identifier )
85
+
86
+ log .warning (f"ASN DB API: returned unexpected format for { identifier } : { raw } " )
87
+ return None
88
+
89
+ def _build_asn_record (self , raw , subnets ):
90
+ """Build standardized ASN record from API response."""
91
+ return {
92
+ "asn" : str (raw .get ("asn" , "" )),
93
+ "subnets" : subnets ,
94
+ "name" : raw .get ("asn_name" ) or "" ,
95
+ "description" : raw .get ("org" ) or "" ,
96
+ "country" : raw .get ("country" ) or "" ,
97
+ }
98
+
57
99
async def asn_to_subnets (self , asn ):
58
100
"""Return subnets for *asn* using cached subnet ranges where possible."""
59
101
# Handle both int and str inputs
@@ -64,7 +106,7 @@ async def asn_to_subnets(self, asn):
64
106
asn_int = int (str (asn .lower ()).lstrip ("as" ))
65
107
except ValueError :
66
108
log .warning (f"Invalid ASN format: { asn } " )
67
- return [ self .UNKNOWN_ASN ]
109
+ return self .UNKNOWN_ASN
68
110
69
111
cached = self ._cache_lookup_asn (asn_int )
70
112
if cached is not None :
@@ -76,7 +118,7 @@ async def asn_to_subnets(self, asn):
76
118
if asn_data :
77
119
self ._cache_store_asn (asn_data , asn_int )
78
120
return asn_data
79
- return [ self .UNKNOWN_ASN ]
121
+ return self .UNKNOWN_ASN
80
122
81
123
async def ip_to_subnets (self , ip : str ):
82
124
"""Return ASN info for *ip* using cached subnet ranges where possible."""
@@ -85,115 +127,65 @@ async def ip_to_subnets(self, ip: str):
85
127
cached = self ._cache_lookup_ip (ip_str )
86
128
if cached is not None :
87
129
log .debug (f"cache HIT for ip: { ip_str } " )
88
- return cached or [ self .UNKNOWN_ASN ]
130
+ return cached or self .UNKNOWN_ASN
89
131
90
132
log .debug (f"cache MISS for ip: { ip_str } " )
91
133
asn_data = await self ._query_api_ip (ip_str )
92
134
if asn_data :
93
135
self ._cache_store_ip (asn_data )
94
136
return asn_data
95
- return [ self .UNKNOWN_ASN ]
137
+ return self .UNKNOWN_ASN
96
138
97
139
async def _query_api_ip (self , ip : str ):
98
- # Build request URL using overridable base
99
- url = f"{ self .asndb_ip_url } { ip } "
100
- response = await self ._request_with_retry (url )
101
- if response is None :
102
- log .warning (f"ASN DB API: no response for { ip } " )
103
- return None
104
-
105
- status = getattr (response , "status_code" , 0 )
106
- if status != 200 :
107
- log .warning (f"ASN DB API: returned { status } for { ip } " )
108
- return None
109
-
110
- try :
111
- raw = response .json ()
112
- except Exception as e :
113
- log .warning (f"ASN DB API: JSON decode error for { ip } : { e } " )
114
- return None
115
-
116
- if isinstance (raw , dict ):
117
- subnets = raw .get ("subnets" )
118
- if isinstance (subnets , str ):
119
- subnets = [subnets ]
120
- if not subnets :
121
- subnets = [f"{ ip } /32" ]
122
-
123
- rec = {
124
- "asn" : str (raw .get ("asn" , "" )),
125
- "subnets" : subnets ,
126
- "name" : raw .get ("asn_name" , "" ),
127
- "description" : raw .get ("org" , "" ),
128
- "country" : raw .get ("country" , "" ),
129
- }
130
- return [rec ]
131
-
132
- log .warning (f"ASN DB API: returned unexpected format for { ip } : { raw } " )
133
- return None
140
+ """Query ASN DB API for IP address information."""
141
+ return await self ._query_api (ip , self .asndb_ip_url , self ._process_ip_response )
142
+
143
+ def _process_ip_response (self , raw , ip ):
144
+ """Process IP lookup response from ASN DB API."""
145
+ subnets = raw .get ("subnets" , [])
146
+ # API returns subnets as array, but handle string case for safety
147
+ if isinstance (subnets , str ):
148
+ subnets = [subnets ]
149
+ if not subnets :
150
+ subnets = [f"{ ip } /32" ]
151
+ return self ._build_asn_record (raw , subnets )
134
152
135
153
async def _query_api_asn (self , asn : str ):
136
- url = f"{ self .asndb_asn_url } { asn } "
137
- response = await self ._request_with_retry (url )
138
- if response is None :
139
- log .warning (f"ASN DB API: no response for { asn } " )
140
- return None
141
-
142
- status = getattr (response , "status_code" , 0 )
143
- if status != 200 :
144
- log .warning (f"ASN DB API: returned { status } for { asn } " )
145
- return None
146
-
147
- try :
148
- raw = response .json ()
149
- except Exception as e :
150
- log .warning (f"ASN DB API: JSON decode error for { asn } : { e } " )
151
- return None
152
-
153
- if isinstance (raw , dict ):
154
- subnets = raw .get ("subnets" )
155
- if isinstance (subnets , str ):
156
- subnets = [subnets ]
157
- if not subnets :
158
- subnets = []
159
-
160
- rec = {
161
- "asn" : str (raw .get ("asn" , "" )),
162
- "subnets" : subnets ,
163
- "name" : raw .get ("asn_name" , "" ),
164
- "description" : raw .get ("org" , "" ),
165
- "country" : raw .get ("country" , "" ),
166
- }
167
- return [rec ]
168
-
169
- log .warning (f"ASN DB API: returned unexpected format for { asn } : { raw } " )
170
- return None
171
-
172
- def _cache_store_asn (self , asn_list , asn_id : int ):
154
+ """Query ASN DB API for ASN information."""
155
+ return await self ._query_api (asn , self .asndb_asn_url , self ._process_asn_response )
156
+
157
+ def _process_asn_response (self , raw , asn ):
158
+ """Process ASN lookup response from ASN DB API."""
159
+ subnets = raw .get ("subnets" , [])
160
+ # API returns subnets as array, but handle string case for safety
161
+ if isinstance (subnets , str ):
162
+ subnets = [subnets ]
163
+ return self ._build_asn_record (raw , subnets )
164
+
165
+ def _cache_store_asn (self , asn_record , asn_id : int ):
173
166
"""Cache ASN data by ASN ID"""
174
- self ._asn_to_data_cache [asn_id ] = asn_list
175
- log .debug (f"ASN cache ADD { asn_id } -> { asn_list [ 0 ] .get ('asn' , '?' ) if asn_list else '?' } " )
167
+ self ._asn_to_data_cache [asn_id ] = asn_record
168
+ log .debug (f"ASN cache ADD { asn_id } -> { asn_record .get ('asn' , '?' ) if asn_record else '?' } " )
176
169
177
170
def _cache_lookup_asn (self , asn_id : int ):
178
171
"""Lookup cached ASN data by ASN ID"""
179
172
return self ._asn_to_data_cache .get (asn_id )
180
173
181
- def _cache_store_ip (self , asn_list ):
174
+ def _cache_store_ip (self , asn_record ):
182
175
if not (self ._tree4 or self ._tree6 ):
183
176
return
184
- for rec in asn_list :
185
- subnets = rec .get ("subnets" ) or []
186
- if isinstance (subnets , str ):
187
- subnets = [subnets ]
188
- for p in subnets :
189
- try :
190
- net = ipaddress .ip_network (p , strict = False )
191
- except ValueError :
192
- continue
193
- tree = self ._tree4 if net .version == 4 else self ._tree6
194
- tree .insert (str (net ), data = asn_list )
195
- self ._subnet_to_asn_cache [str (net )] = asn_list
196
- log .debug (f"IP cache ADD { net } -> { asn_list [:1 ][0 ].get ('asn' , '?' )} " )
177
+ subnets = asn_record .get ("subnets" ) or []
178
+ if isinstance (subnets , str ):
179
+ subnets = [subnets ]
180
+ for p in subnets :
181
+ try :
182
+ net = ipaddress .ip_network (p , strict = False )
183
+ except ValueError :
184
+ continue
185
+ tree = self ._tree4 if net .version == 4 else self ._tree6
186
+ tree .insert (str (net ), data = asn_record )
187
+ self ._subnet_to_asn_cache [str (net )] = asn_record
188
+ log .debug (f"IP cache ADD { net } -> { asn_record .get ('asn' , '?' )} " )
197
189
198
190
def _cache_lookup_ip (self , ip : str ):
199
191
ip_obj = ipaddress .ip_address (ip )
0 commit comments