]> git.openstreetmap.org Git - nominatim.git/blob - utils/cron_ipanalyse.py
Merge remote-tracking branch 'upstream/master'
[nominatim.git] / utils / cron_ipanalyse.py
1 #!/usr/bin/python3
2 #
3 # Search apache logs for high-bandwith users and create a list of suspicious IPs.
4 # There are three states: bulk, block, ban. The first are bulk requesters
5 # that need throtteling, the second bulk requesters that have overdone it
6 # and the last manually banned IPs.
7 #
8
9 import re
10 import os
11 import sys
12 import subprocess
13 from datetime import datetime, timedelta
14 from collections import defaultdict
15
16 #
17 # DEFAULT SETTINGS
18 #
19 # Copy into settings/ip_blcoks.conf and adapt as required.
20 #
21 BASEDIR = os.path.normpath(os.path.join(os.path.realpath(__file__), '../..'))
22 BLOCKEDFILE= BASEDIR + '/settings/ip_blocks.map'
23 LOGFILE= BASEDIR + '/log/restricted_ip.log'
24
25 # space-separated list of IPs that are never banned
26 WHITELIST = ''
27 # space-separated list of IPs manually blocked
28 BLACKLIST = ''
29 # user-agents that should be blocked from bulk mode
30 # (matched with startswith)
31 UA_BLOCKLIST = ()
32
33 # time before a automatically blocked IP is allowed back
34 BLOCKCOOLOFF_DELTA=timedelta(hours=1)
35 # quiet time before an IP is released from the bulk pool
36 BULKCOOLOFF_DELTA=timedelta(minutes=15)
37
38 BULKLONG_LIMIT=8000
39 BULKSHORT_LIMIT=2000
40 BLOCK_UPPER=19000
41 BLOCK_LOWER=4000
42 BLOCK_LOADFAC=380
43 BULK_LOADFAC=160
44 BULK_LOWER=1500
45 MAX_BULK_IPS=85
46
47 #
48 # END OF DEFAULT SETTINGS
49 #
50
51 try:
52     with open(BASEDIR + "/settings/ip_blocks.conf") as f:
53         code = compile(f.read(), BASEDIR + "/settings/ip_blocks.conf", 'exec')
54         exec(code)
55 except IOError:
56     pass
57
58 BLOCK_LIMIT = BLOCK_LOWER
59
60 time_regex = r'(?P<t_day>\d\d)/(?P<t_month>[A-Za-z]+)/(?P<t_year>\d\d\d\d):(?P<t_hour>\d\d):(?P<t_min>\d\d):(?P<t_sec>\d\d) [+-]\d\d\d\d'
61
62 format_pat= re.compile(r'(?P<ip>[a-f\d\.:]+) - - \['+ time_regex + r'] "(?P<query>.*?)" (?P<return>\d+) (?P<bytes>\d+) "(?P<referer>.*?)" "(?P<ua>.*?)"')
63 time_pat= re.compile(r'[a-f\d:\.]+ - - \[' + time_regex + '\] ')
64
65 logtime_pat = "%d/%b/%Y:%H:%M:%S %z"
66
67 MONTHS = { 'Jan' : 1, 'Feb' : 2, 'Mar' : 3, 'Apr' : 4, 'May' : 5, 'Jun' : 6,
68            'Jul' : 7, 'Aug' : 8, 'Sep' : 9, 'Oct' : 10, 'Nov' : 11, 'Dec' : 12 }
69
70 class LogEntry:
71     def __init__(self, logline):
72         e = format_pat.match(logline)
73         if e is None:
74             raise ValueError("Invalid log line:", logline)
75         e = e.groupdict()
76         self.ip = e['ip']
77         self.date = datetime(int(e['t_year']), MONTHS[e['t_month']], int(e['t_day']),
78                              int(e['t_hour']), int(e['t_min']), int(e['t_sec']))
79         qp = e['query'].split(' ', 2) 
80         if len(qp) < 2:
81             self.request = None
82             self.query = None
83         else:
84             self.query = qp[1]
85             if qp[0] == 'OPTIONS':
86                 self.request = None
87             else:
88                 if '/search' in qp[1]:
89                     self.request = 'S'
90                 elif '/reverse' in qp[1]:
91                     self.request = 'R'
92                 elif '/details' in qp[1]:
93                     self.request = 'D'
94                 elif '/lookup' in qp[1]:
95                     self.request = 'L'
96                 else:
97                     self.request = None
98         self.query = e['query']
99         self.retcode = int(e['return'])
100         self.referer = e['referer'] if e['referer'] != '-' else None
101         self.ua = e['ua'] if e['ua'] != '-' else None
102
103     def get_log_time(logline):
104         e = format_pat.match(logline)
105         if e is None:
106             return None
107         e = e.groupdict()
108         #return datetime.strptime(e['time'], logtime_pat).replace(tzinfo=None)
109         return datetime(int(e['t_year']), MONTHS[e['t_month']], int(e['t_day']),
110                              int(e['t_hour']), int(e['t_min']), int(e['t_sec']))
111
112
113 class LogFile:
114     """ An apache log file, unpacked. """
115
116     def __init__(self, filename):
117         self.fd = open(filename)
118         self.len = os.path.getsize(filename)
119
120     def __del__(self):
121         self.fd.close()
122
123     def seek_next(self, abstime):
124         self.fd.seek(abstime)
125         self.fd.readline()
126         l = self.fd.readline()
127         return LogEntry.get_log_time(l) if l is not None else None
128
129     def seek_to_date(self, target):
130         # start position for binary search
131         fromseek = 0
132         fromdate = self.seek_next(0)
133         if fromdate > target:
134             return True
135         # end position for binary search
136         toseek = -100
137         while -toseek < self.len:
138             todate = self.seek_next(self.len + toseek)
139             if todate is not None:
140                 break
141             toseek -= 100
142         if todate is None or todate < target:
143             return False
144         toseek = self.len + toseek
145
146
147         while True:
148             bps = (toseek - fromseek) / (todate - fromdate).total_seconds()
149             newseek = fromseek + int((target - fromdate).total_seconds() * bps)
150             newdate = self.seek_next(newseek)
151             if newdate is None:
152                 return False;
153             error = abs((target - newdate).total_seconds())
154             if error < 1:
155                 return True
156             if newdate > target:
157                 toseek = newseek
158                 todate = newdate
159                 oldfromseek = fromseek
160                 fromseek = toseek - error * bps
161                 while True:
162                     if fromseek <= oldfromseek:
163                         fromseek = oldfromseek
164                         fromdate = self.seek_next(fromseek)
165                         break
166                     fromdate = self.seek_next(fromseek)
167                     if fromdate < target:
168                         break;
169                     bps *=2
170                     fromseek -= error * bps
171             else:
172                 fromseek = newseek
173                 fromdate = newdate
174                 oldtoseek = toseek
175                 toseek = fromseek + error * bps
176                 while True:
177                     if toseek > oldtoseek:
178                         toseek = oldtoseek
179                         todate = self.seek_next(toseek)
180                         break
181                     todate = self.seek_next(toseek)
182                     if todate > target:
183                         break
184                     bps *=2
185                     toseek += error * bps
186             if toseek - fromseek < 500:
187                 return True
188
189
190     def loglines(self):
191         for l in self.fd:
192             try:
193                 yield LogEntry(l)
194             except ValueError:
195                 pass # ignore invalid lines
196
197 class BlockList:
198
199     def __init__(self):
200         self.whitelist = set(WHITELIST.split()) if WHITELIST else set()
201         self.blacklist = set(BLACKLIST.split()) if BLACKLIST else set()
202         self.prevblocks = set()
203         self.prevbulks = set()
204
205         try:
206             fd = open(BLOCKEDFILE)
207             for line in fd:
208                 ip, typ = line.strip().split(' ')
209                 if ip not in self.blacklist:
210                     if typ == 'block':
211                         self.prevblocks.add(ip)
212                     elif typ == 'bulk':
213                         self.prevbulks.add(ip)
214             fd.close()
215         except IOError:
216             pass #ignore non-existing file
217
218
219 class IPstats:
220
221     def __init__(self):
222         self.short_total = 0
223         self.short_api = 0
224         self.long_total = 0
225         self.long_api = 0
226         self.bad_ua = False
227
228     def add_long(self, logentry):
229         self.long_total += 1
230         if logentry.request is not None:
231             self.long_api += 1
232         if not self.bad_ua:
233             if logentry.ua is None:
234                 self.bad_ua = True
235
236     def add_short(self, logentry):
237         self.short_total += 1
238         if logentry.request is not None:
239             self.short_api += 1
240         self.add_long(logentry)
241
242     def new_state(self, was_blocked, was_bulked):
243         if was_blocked:
244             # deblock only if the IP has been really quiet
245             # (properly catches the ones that simply ignore the HTTP error)
246             return None if self.long_total < 20 else 'block'
247         if self.long_api > BLOCK_UPPER or self.short_api > BLOCK_UPPER / 3:
248                 # client totally overdoing it
249                 return 'block'
250         if was_bulked:
251             if self.short_total < 20:
252                 # client has stopped, debulk
253                 return None
254             if self.long_api > BLOCK_LIMIT or self.short_api > BLOCK_LIMIT / 3:
255                 # client is still hammering us, block
256                 return 'emblock'
257             return 'bulk'
258
259         if self.long_api > BULKLONG_LIMIT or self.short_api > BULKSHORT_LIMIT:
260             #if self.bad_ua:
261             #    return 'uablock' # bad useragent
262             return 'bulk'
263
264         return None
265
266
267
268 if __name__ == '__main__':
269     if len(sys.argv) < 2:
270         print("Usage: %s logfile startdate" % sys.argv[0])
271         sys.exit(-1)
272
273     if len(sys.argv) == 2:
274         dt = datetime.now() - BLOCKCOOLOFF_DELTA
275     else:
276         dt = datetime.strptime(sys.argv[2], "%Y-%m-%d %H:%M:%S")
277
278     if os.path.getsize(sys.argv[1]) < 2*1030*1024:
279         sys.exit(0) # not enough data
280
281     lf = LogFile(sys.argv[1])
282     if not lf.seek_to_date(dt):
283         sys.exit(0)
284
285     bl = BlockList()
286
287     shortstart = dt + BLOCKCOOLOFF_DELTA - BULKCOOLOFF_DELTA
288     notlogged = bl.whitelist | bl.blacklist
289
290     stats = defaultdict(IPstats)
291
292     for l in lf.loglines():
293         if l.ip not in notlogged:
294             stats[l.ip].add_long(l)
295         if l.date > shortstart:
296             break
297
298     total200 = 0
299     for l in lf.loglines():
300         if l.ip not in notlogged:
301             stats[l.ip].add_short(l)
302         if l.request is not None and l.retcode == 200:
303             total200 += 1
304
305     # adapt limits according to CPU and DB load
306     fd = open("/proc/loadavg")
307     cpuload = int(float(fd.readline().split()[2]))
308     fd.close()
309     # check the number of excess connections to apache
310     dbcons = int(subprocess.check_output("netstat -s | grep 'connections established' | sed 's:^\s*::;s: .*::'", shell=True))
311     fpms = int(subprocess.check_output('ps -Af | grep php-fpm | wc -l', shell=True))
312     dbload = max(0, dbcons - fpms)
313
314     numbulks = len(bl.prevbulks)
315     BLOCK_LIMIT = max(BLOCK_LIMIT, BLOCK_UPPER - BLOCK_LOADFAC * dbload)
316     BULKLONG_LIMIT = max(BULK_LOWER, BULKLONG_LIMIT - BULK_LOADFAC * cpuload)
317     if numbulks > MAX_BULK_IPS:
318         BLOCK_LIMIT = max(3600, BLOCK_LOWER - (numbulks - MAX_BULK_IPS)*10)
319     # if the bulk pool is still empty, clients will be faster, avoid having
320     # them blocked in this case
321     if numbulks < 10:
322         BLOCK_UPPER *= 2
323         BLOCK_LIMIT = BLOCK_UPPER
324
325
326     # collecting statistics
327     unblocked = []
328     debulked = []
329     bulked = []
330     blocked = []
331     uablocked = []
332     emblocked = []
333     # write out new state file
334     fd = open(BLOCKEDFILE, 'w')
335     for k,v in stats.items():
336         wasblocked = k in bl.prevblocks
337         wasbulked = k in bl.prevbulks
338         state = v.new_state(wasblocked, wasbulked)
339         if state is not None:
340             if state == 'uablock':
341                 uablocked.append(k)
342                 state = 'block'
343             elif state == 'emblock':
344                 emblocked.append(k)
345                 state = 'block'
346             elif state == 'block':
347                 if not wasblocked:
348                     blocked.append(k)
349             elif state == 'bulk':
350                 if not wasbulked:
351                     bulked.append(k)
352             fd.write("%s %s\n" % (k, state))
353         else:
354             if wasblocked:
355                 unblocked.append(k)
356             elif wasbulked:
357                 debulked.append(k)
358     for i in bl.blacklist:
359         fd.write("%s ban\n" % i)
360     fd.close()
361
362     # TODO write logs (need to collect some statistics)
363     logstr = datetime.now().strftime('%Y-%m-%d %H:%M') + ' %s %s\n'
364     fd = open(LOGFILE, 'a')
365     if unblocked:
366         fd.write(logstr % ('unblocked:', ', '.join(unblocked)))
367     if debulked:
368         fd.write(logstr % (' debulked:', ', '.join(debulked)))
369     if bulked:
370         fd.write(logstr % ('new bulks:', ', '.join(bulked)))
371     if emblocked:
372         fd.write(logstr % ('dir.block:', ', '.join(emblocked)))
373     if uablocked:
374         fd.write(logstr % (' ua block:', ', '.join(uablocked)))
375     if blocked:
376         fd.write(logstr % ('new block:', ', '.join(blocked)))
377     fd.close()