OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/env python |
| 2 |
| 3 ''' This runs netstat on a local or remote server. It calculates some simple |
| 4 statistical information on the number of external inet connections. It groups |
| 5 by IP address. This can be used to detect if one IP address is taking up an |
| 6 excessive number of connections. It can also send an email alert if a given IP |
| 7 address exceeds a threshold between runs of the script. This script can be used |
| 8 as a drop-in Munin plugin or it can be used stand-alone from cron. I used this |
| 9 on a busy web server that would sometimes get hit with denial of service |
| 10 attacks. This made it easy to see if a script was opening many multiple |
| 11 connections. A typical browser would open fewer than 10 connections at once. |
| 12 A script might open over 100 simultaneous connections. |
| 13 |
| 14 ./topip.py [-s server_hostname] [-u username] [-p password] |
| 15 {-a from_addr,to_addr} {-n N} {-v} {--ipv6} |
| 16 |
| 17 -s : hostname of the remote server to login to. |
| 18 -u : username to user for login. |
| 19 -p : password to user for login. |
| 20 -n : print stddev for the the number of the top 'N' ipaddresses. |
| 21 -v : verbose - print stats and list of top ipaddresses. |
| 22 -a : send alert if stddev goes over 20. |
| 23 -l : to log message to /var/log/topip.log |
| 24 --ipv6 : this parses netstat output that includes ipv6 format. |
| 25 Note that this actually only works with ipv4 addresses, but for |
| 26 versions of netstat that print in ipv6 format. |
| 27 --stdev=N : Where N is an integer. This sets the trigger point |
| 28 for alerts and logs. Default is to trigger if the |
| 29 max value is over 5 standard deviations. |
| 30 |
| 31 Example: |
| 32 |
| 33 This will print stats for the top IP addresses connected to the given host: |
| 34 |
| 35 ./topip.py -s www.example.com -u mylogin -p mypassword -n 10 -v |
| 36 |
| 37 This will send an alert email if the maxip goes over the stddev trigger |
| 38 value and the the current top ip is the same as the last top ip |
| 39 (/tmp/topip.last): |
| 40 |
| 41 ./topip.py -s www.example.com -u mylogin -p mypassword \\ |
| 42 -n 10 -v -a alert@example.com,user@example.com |
| 43 |
| 44 This will print the connection stats for the localhost in Munin format: |
| 45 |
| 46 ./topip.py |
| 47 |
| 48 PEXPECT LICENSE |
| 49 |
| 50 This license is approved by the OSI and FSF as GPL-compatible. |
| 51 http://opensource.org/licenses/isc-license.txt |
| 52 |
| 53 Copyright (c) 2012, Noah Spurrier <noah@noah.org> |
| 54 PERMISSION TO USE, COPY, MODIFY, AND/OR DISTRIBUTE THIS SOFTWARE FOR ANY |
| 55 PURPOSE WITH OR WITHOUT FEE IS HEREBY GRANTED, PROVIDED THAT THE ABOVE |
| 56 COPYRIGHT NOTICE AND THIS PERMISSION NOTICE APPEAR IN ALL COPIES. |
| 57 THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES |
| 58 WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
| 59 MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR |
| 60 ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
| 61 WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
| 62 ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF |
| 63 OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
| 64 |
| 65 ''' |
| 66 |
| 67 from __future__ import absolute_import |
| 68 from __future__ import print_function |
| 69 from __future__ import unicode_literals |
| 70 |
| 71 # See http://pexpect.sourceforge.net/ |
| 72 import pexpect |
| 73 import pxssh |
| 74 import os |
| 75 import sys |
| 76 import time |
| 77 import getopt |
| 78 import pickle |
| 79 import getpass |
| 80 import smtplib |
| 81 from pprint import pprint |
| 82 |
| 83 |
| 84 try: |
| 85 raw_input |
| 86 except NameError: |
| 87 raw_input = input |
| 88 |
| 89 |
| 90 TOPIP_LOG_FILE = '/var/log/topip.log' |
| 91 TOPIP_LAST_RUN_STATS = '/var/run/topip.last' |
| 92 |
| 93 def exit_with_usage(): |
| 94 |
| 95 print(globals()['__doc__']) |
| 96 os._exit(1) |
| 97 |
| 98 def stats(r): |
| 99 |
| 100 '''This returns a dict of the median, average, standard deviation, |
| 101 min and max of the given sequence. |
| 102 |
| 103 >>> from topip import stats |
| 104 >>> print stats([5,6,8,9]) |
| 105 {'med': 8, 'max': 9, 'avg': 7.0, 'stddev': 1.5811388300841898, 'min': 5} |
| 106 >>> print stats([1000,1006,1008,1014]) |
| 107 {'med': 1008, 'max': 1014, 'avg': 1007.0, 'stddev': 5.0, 'min': 1000} |
| 108 >>> print stats([1,3,4,5,18,16,4,3,3,5,13]) |
| 109 {'med': 4, 'max': 18, 'avg': 6.8181818181818183, 'stddev': 5.621681757723747
5, 'min': 1} |
| 110 >>> print stats([1,3,4,5,18,16,4,3,3,5,13,14,5,6,7,8,7,6,6,7,5,6,4,14,7]) |
| 111 {'med': 6, 'max': 18, 'avg': 7.0800000000000001, 'stddev': 4.325921867070647
4, 'min': 1} |
| 112 ''' |
| 113 |
| 114 total = sum(r) |
| 115 avg = float(total)/float(len(r)) |
| 116 sdsq = sum([(i-avg)**2 for i in r]) |
| 117 s = sorted(list(r)) |
| 118 return dict(list(zip(['med', 'avg', 'stddev', 'min', 'max'], |
| 119 (s[len(s)//2], avg, (sdsq/len(r))**.5, min(r), max(r))))) |
| 120 |
| 121 def send_alert (message, subject, addr_from, addr_to, smtp_server='localhost'): |
| 122 |
| 123 '''This sends an email alert. |
| 124 ''' |
| 125 |
| 126 message = ( 'From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n' |
| 127 % (addr_from, addr_to, subject) + message ) |
| 128 server = smtplib.SMTP(smtp_server) |
| 129 server.sendmail(addr_from, addr_to, message) |
| 130 server.quit() |
| 131 |
| 132 def main(): |
| 133 |
| 134 # Parse the options, arguments, etc. |
| 135 try: |
| 136 optlist, args = getopt.getopt(sys.argv[1:], |
| 137 'h?valqs:u:p:n:', ['help','h','?','ipv6','stddev=']) |
| 138 except Exception as e: |
| 139 print(str(e)) |
| 140 exit_with_usage() |
| 141 options = dict(optlist) |
| 142 |
| 143 munin_flag = False |
| 144 if len(args) > 0: |
| 145 if args[0] == 'config': |
| 146 print('graph_title Netstat Connections per IP') |
| 147 print('graph_vlabel Socket connections per IP') |
| 148 print('connections_max.label max') |
| 149 print('connections_max.info Maximum number of connections per IP') |
| 150 print('connections_avg.label avg') |
| 151 print('connections_avg.info Average number of connections per IP') |
| 152 print('connections_stddev.label stddev') |
| 153 print('connections_stddev.info Standard deviation') |
| 154 return 0 |
| 155 elif args[0] != '': |
| 156 print(args, len(args)) |
| 157 return 0 |
| 158 exit_with_usage() |
| 159 if [elem for elem in options if elem in ['-h','--h','-?','--?','--help']]: |
| 160 print('Help:') |
| 161 exit_with_usage() |
| 162 if '-s' in options: |
| 163 hostname = options['-s'] |
| 164 else: |
| 165 # if host was not specified then assume localhost munin plugin. |
| 166 munin_flag = True |
| 167 hostname = 'localhost' |
| 168 # If localhost then don't ask for username/password. |
| 169 if hostname != 'localhost' and hostname != '127.0.0.1': |
| 170 if '-u' in options: |
| 171 username = options['-u'] |
| 172 else: |
| 173 username = raw_input('username: ') |
| 174 if '-p' in options: |
| 175 password = options['-p'] |
| 176 else: |
| 177 password = getpass.getpass('password: ') |
| 178 use_localhost = False |
| 179 else: |
| 180 use_localhost = True |
| 181 |
| 182 if '-l' in options: |
| 183 log_flag = True |
| 184 else: |
| 185 log_flag = False |
| 186 if '-n' in options: |
| 187 average_n = int(options['-n']) |
| 188 else: |
| 189 average_n = None |
| 190 if '-v' in options: |
| 191 verbose = True |
| 192 else: |
| 193 verbose = False |
| 194 if '-a' in options: |
| 195 alert_flag = True |
| 196 (alert_addr_from, alert_addr_to) = tuple(options['-a'].split(',')) |
| 197 else: |
| 198 alert_flag = False |
| 199 if '--ipv6' in options: |
| 200 ipv6_flag = True |
| 201 else: |
| 202 ipv6_flag = False |
| 203 if '--stddev' in options: |
| 204 stddev_trigger = float(options['--stddev']) |
| 205 else: |
| 206 stddev_trigger = 5 |
| 207 |
| 208 if ipv6_flag: |
| 209 netstat_pattern = '(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+::ffff:(\S+):(\S+)\s+
.*?\r' |
| 210 else: |
| 211 netstat_pattern = '(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(?:::ffff:)*(\S+):(\S
+)\s+.*?\r' |
| 212 #netstat_pattern = '(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+):(\S+)\s+.*?\r' |
| 213 |
| 214 # run netstat (either locally or via SSH). |
| 215 if use_localhost: |
| 216 p = pexpect.spawn('netstat -n -t') |
| 217 PROMPT = pexpect.TIMEOUT |
| 218 else: |
| 219 p = pxssh.pxssh() |
| 220 p.login(hostname, username, password) |
| 221 p.sendline('netstat -n -t') |
| 222 PROMPT = p.PROMPT |
| 223 |
| 224 # For each matching netstat_pattern put the ip address in the list. |
| 225 ip_list = {} |
| 226 try: |
| 227 while 1: |
| 228 i = p.expect([PROMPT, netstat_pattern]) |
| 229 if i == 0: |
| 230 break |
| 231 k = p.match.groups()[4].decode('utf-8') |
| 232 if k in ip_list: |
| 233 ip_list[k] = ip_list[k] + 1 |
| 234 else: |
| 235 ip_list[k] = 1 |
| 236 except: |
| 237 pass |
| 238 |
| 239 # remove a few common, uninteresting addresses from the dictionary. |
| 240 ip_list = dict([ (key,value) for key,value in ip_list.items() if '192.168.'
not in key]) |
| 241 ip_list = dict([ (key,value) for key,value in ip_list.items() if '127.0.0.1'
not in key]) |
| 242 |
| 243 ip_list = list(ip_list.items()) |
| 244 if len(ip_list) < 1: |
| 245 if verbose: print('Warning: no networks connections worth looking at.') |
| 246 return 0 |
| 247 ip_list.sort(key=lambda x:x[1]) |
| 248 |
| 249 # generate some stats for the ip addresses found. |
| 250 if average_n is not None and average_n <= 1: |
| 251 average_n = None |
| 252 # Reminder: the * unary operator treats the list elements as arguments. |
| 253 zipped = zip(*ip_list[0:average_n]) |
| 254 s = stats(list(zipped)[1]) |
| 255 s['maxip'] = ip_list[0] |
| 256 |
| 257 # print munin-style or verbose results for the stats. |
| 258 if munin_flag: |
| 259 print('connections_max.value', s['max']) |
| 260 print('connections_avg.value', s['avg']) |
| 261 print('connections_stddev.value', s['stddev']) |
| 262 return 0 |
| 263 if verbose: |
| 264 pprint (s) |
| 265 print() |
| 266 pprint (ip_list[0:average_n]) |
| 267 |
| 268 # load the stats from the last run. |
| 269 try: |
| 270 last_stats = pickle.load(file(TOPIP_LAST_RUN_STATS)) |
| 271 except: |
| 272 last_stats = {'maxip':None} |
| 273 |
| 274 if ( s['maxip'][1] > (s['stddev'] * stddev_trigger) |
| 275 and s['maxip']==last_stats['maxip'] ): |
| 276 if verbose: print('The maxip has been above trigger for two consecutive
samples.') |
| 277 if alert_flag: |
| 278 if verbose: print('SENDING ALERT EMAIL') |
| 279 send_alert(str(s), 'ALERT on %s' |
| 280 % hostname, alert_addr_from, alert_addr_to) |
| 281 if log_flag: |
| 282 if verbose: print('LOGGING THIS EVENT') |
| 283 fout = file(TOPIP_LOG_FILE,'a') |
| 284 #dts = time.strftime('%Y:%m:%d:%H:%M:%S', time.localtime()) |
| 285 dts = time.asctime() |
| 286 fout.write ('%s - %d connections from %s\n' |
| 287 % (dts,s['maxip'][1],str(s['maxip'][0]))) |
| 288 fout.close() |
| 289 |
| 290 # save state to TOPIP_LAST_RUN_STATS |
| 291 try: |
| 292 pickle.dump(s, file(TOPIP_LAST_RUN_STATS,'w')) |
| 293 os.chmod (TOPIP_LAST_RUN_STATS, 0o664) |
| 294 except: |
| 295 pass |
| 296 # p.logout() |
| 297 |
| 298 if __name__ == '__main__': |
| 299 main() |
OLD | NEW |