#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Multi-source traceroute with geolocation information.
"""
import datetime
import json
import optparse
import os
import re
import signal
import sys
import urllib
import urllib2
from subprocess import Popen, PIPE
import requests
import netifaces
import time
USER_AGENT = "traceroute/1.0 (+https://github.com/ayeowch/traceroute)"
class Traceroute(object):
"""
Multi-source traceroute instance.
"""
def __init__(self, ip_address, source=None, country="US", tmp_dir="/tmp",
no_geo=False, timeout=120, debug=False):
super(Traceroute, self).__init__()
self.ip_address = ip_address
self.source = source
if self.source is None:
json_file = open("sources.json", "r").read()
sources = json.loads(json_file.replace("_IP_ADDRESS_", ip_address))
self.source = sources[country]
self.tmp_dir = tmp_dir
self.no_geo = no_geo
self.timeout = timeout
self.debug = debug
self.locations = {}
self.hops = {}
self.country = country
# Localhost Specific operations happen here
if self.country == 'LO':
self.local_mode = True
self.pub_ip = self.__lookup_public_ip()
self.ifaces = self.__get_network_interface_info()
self.routes = self.__get_network_routes()
else:
self.local_mode = False
# Store start/end times of the traceroute process
self.probe_start = time.time() * 1000
self.__run_traceroute()
self.probe_end = time.time() * 1000
def __run_traceroute(self):
"""
Instead of running the actual traceroute command, we will fetch
standard traceroute results from several publicly available webpages
that are listed at traceroute.org. For each hop, we will then attach
geolocation information to it.
"""
self.print_debug("ip_address={}".format(self.ip_address))
filename = "{}.{}.txt".format(self.ip_address, self.country)
filepath = os.path.join(self.tmp_dir, filename)
if not os.path.exists(filepath):
if self.country == "LO":
status_code, traceroute = self.execute_cmd(self.source['url'])
else:
status_code, traceroute = self.get_traceroute_output()
if status_code != 0 and status_code != 200:
return {'error': status_code}
open(filepath, "w").write(traceroute)
traceroute = open(filepath, "r").read()
self.raw_string = traceroute
self.__get_hops(traceroute)
#if not self.no_geo:
# self.__get_geocoded_hops()
#self.hops = map(lambda h: {h.pop("hop_num") : h}, self.hops)
def get_traceroute_output(self):
"""
Fetches traceroute output from a webpage.
"""
url = self.source['url']
if 'post_data' in self.source:
context = self.source['post_data']
else:
context = None
status_code, content = self.urlopen(url, context=context)
content = content.strip()
regex = r'
(?P.*?)'
pattern = re.compile(regex, re.DOTALL | re.IGNORECASE)
try:
traceroute = re.findall(pattern, content)[0].strip()
except IndexError:
# Manually append closing for partially downloaded page
content = "{}".format(content)
traceroute = re.findall(pattern, content)[0].strip()
return (status_code, traceroute)
def __get_hops(self, traceroute):
"""
Returns hops from traceroute output in an array of dicts each
with hop number and the associated hosts data.
hop_context = {
'hop_num': hop_num,
'hostname': host[0],
'ip_address': host[1],
'rtt': host[2],
}
"""
# This breaks up the line into hop num => host data
#hop_pattern = '^(?P\w+)\s+(?P.*)'
hop_pattern = '^(?P[0-9]+)\s+(?P.*)'
# This matches hosts which are ip or dns mapped
host_pattern = '([\d\w.-]+\s+\(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\)\s+\d+\.\d+ ms)'
# This is essentially the same as the previous pattern but breaks into usable chunks
hop_element_pattern = '([\d\w.-]+)\s+\((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\)\s+(\d+\.\d+ ms)'
hp = re.compile(hop_element_pattern)
for entry in traceroute.split('\n'):
entry = entry.strip()
result = re.match(hop_pattern,entry)
if result is None: # should only fail on first line
continue
hop = result.groupdict()
hop_num = int(hop['hop_num'])
hop_hosts = re.findall(host_pattern, hop['hosts'])
self.hops[hop_num] = []
for host in hop_hosts:
m = hp.search(host)
(hostname, ip, ping_time) = m.groups()
if self.no_geo:
self.hops[hop_num].append(
{
'hostname' : hostname,
'ip_address' : ip,
'rtt' : ping_time
}
)
else:
location = self.__get_geocoded_data(ip)
if location:
self.hops[hop_num].append(
{
'hostname' : hostname,
'ip_address' : ip,
'rtt' : ping_time,
'latitude' : location['latitude'],
'longitude' : location['longitude']
}
)
else:
self.hops[hop_num].append(
{
'hostname' : hostname,
'ip_address' : ip,
'rtt' : ping_time
}
)
def __get_geocoded_data(self, ip_address):
"""
Returns a location hash with long/lat for a particular IP address
"""
location = None
if ip_address in self.locations:
location = self.locations[ip_address]
else:
location = self.get_location(ip_address)
self.locations[ip_address] = location
return location
def get_location(self, ip_address):
"""
Returns geolocation information for the given IP address.
"""
location = None
url = "http://dazzlepod.com/ip/{}.json".format(ip_address)
status_code, json_data = self.urlopen(url)
if status_code == 200 and json_data:
tmp_location = json.loads(json_data)
if 'latitude' in tmp_location and 'longitude' in tmp_location:
location = tmp_location
return location
def execute_cmd(self, cmd):
"""
Executes given command using subprocess.Popen().
"""
stdout = ""
returncode = -1
process = Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE)
try:
signal.signal(signal.SIGALRM, self.signal_handler)
signal.alarm(self.timeout)
stdout, stderr = process.communicate()
returncode = process.returncode
self.print_debug("cmd={}, returncode={}".format(cmd, returncode))
if returncode != 0:
self.print_debug("stderr={}".format(stderr))
signal.alarm(0)
except Exception as err:
self.print_debug(str(err))
return (returncode, stdout)
def __lookup_public_ip(self):
"""
Retrieve public IP of this instance by calling ipify webservice
"""
response = requests.get('https://api.ipify.org?format=json', timeout=self.timeout)
if response.status_code == 200:
ip_data = response.json()
if 'ip' not in ip_data.keys():
return 'Unable to determine IP'
else:
return ip_data['ip']
else:
return 'Unable to determine IP'
def __get_network_interface_info(self):
"""
Private method.
Gather list of active interfaces - localhost mode only.
"""
iface_list = []
for i in netifaces.interfaces():
addr = netifaces.ifaddresses(i)
# clumsy way to filter which interfaces get added to list. If these elements raise KeyErrors, we skip
try:
iface_list.append( {i : {
'ip_address' : addr[netifaces.AF_INET][0]['addr'],
'mac' : addr[netifaces.AF_LINK][0]['addr']
}})
except KeyError,e:
self.print_debug("Key not found - _get_network_interface_info - {}".format(addr))
return iface_list
def __get_network_routes(self):
"""
Gather network routes on localhost. Only grabs default gateway. Need to play around on different hosts to see what output
should be
"""
routes = []
gw = netifaces.gateways()
if 'default' in gw.keys():
routes.append( {
'default' : gw['default'][netifaces.AF_INET]
})
return routes
def urlopen(self, url, context=None):
"""
Fetches webpage.
"""
status_code = 200
request = urllib2.Request(url=url)
request.add_header('User-Agent', USER_AGENT)
if context:
data = urllib.urlencode(context)
request.add_data(data)
content = ""
try:
response = urllib2.urlopen(request)
self.print_debug("url={}".format(response.geturl()))
content = self.chunked_read(response)
except urllib2.HTTPError as err:
status_code = err.code
except urllib2.URLError:
pass
return (status_code, content)
def chunked_read(self, response):
"""
Fetches response in chunks. A signal handler is attached to abort
reading after set timeout.
"""
content = ""
max_bytes = 1 * 1024 * 1024 # Max. page size = 1MB
read_bytes = 0
bytes_per_read = 64 # Chunk size = 64 bytes
try:
signal.signal(signal.SIGALRM, self.signal_handler)
signal.alarm(self.timeout)
while read_bytes <= max_bytes:
data = response.read(bytes_per_read)
if not data:
break
content += data
read_bytes += bytes_per_read
self.print_debug("read_bytes={}, {}".format(read_bytes, data))
signal.alarm(0)
except Exception as err:
self.print_debug(str(err))
return content
def signal_handler(self, signum):
"""
Raises exception when signal is caught.
"""
raise Exception("Caught signal {}".format(signum))
def print_debug(self, msg):
"""
Prints debug message to standard output.
"""
if self.debug:
print("[DEBUG {}] {}".format(datetime.datetime.now(), msg))
def get_report(self):
report = {}
report['hops'] = self.hops
report['probe_start'] = self.probe_start
report['probe_end'] = self.probe_end
if self.local_mode:
report['pub_ip'] = self.pub_ip
report['ifaces'] = self.ifaces
report['routes'] = self.routes
if self.debug:
report['raw'] = self.raw_string
return report
def post_result(webhook_url, report, timeout=120):
"""
POST traceroute report to specified website. Exceptions need to be caught in the caller
"""
return requests.post(webhook_url, data=json.dumps(report), timeout=timeout)
def main():
cmdparser = optparse.OptionParser("%prog --ip_address=IP_ADDRESS")
cmdparser.add_option(
"-i", "--ip_address", type="string", default="8.8.8.8",
help="IP address of destination host (default: 8.8.8.8)")
cmdparser.add_option(
"-j", "--json_file", type="string", default="sources.json",
help="List of sources in JSON file (default: sources.json)")
cmdparser.add_option(
"-c", "--country", type="choice", default="LO",
choices=["LO", "BY", "CH", "JP", "RU", "UK", "US"],
help=("Traceroute will be initiated from this country; choose 'LO' "
"for localhost to run traceroute locally, 'BY' for Belarus, "
"'CH' for Switzerland, 'JP' for Japan, 'RU' for Russia, 'UK' "
"for United Kingdom or 'US' for United States (default: US)"))
cmdparser.add_option(
"-t", "--tmp_dir", type="string", default="/tmp",
help=("Temporary directory to store downloaded traceroute results "
"(default: /tmp)"))
cmdparser.add_option(
"-n", "--no_geo", action="store_true", default=False,
help="No geolocation data (default: False)")
cmdparser.add_option(
"-s", "--timeout", type="int", default=120,
help="Timeout in seconds for all downloads (default: 120)")
cmdparser.add_option(
"-d", "--debug", action="store_true", default=False,
help="Show debug output (default: False)")
cmdparser.add_option(
"-w", "--webhook", type="string", default="",
help="Specify URL to POST report payload rather than stdout")
options, _ = cmdparser.parse_args()
json_file = open(options.json_file, "r").read()
sources = json.loads(json_file.replace("_IP_ADDRESS_", options.ip_address))
# Get Hope info using Traceroute Object
traceroute = Traceroute(ip_address=options.ip_address,
source=sources[options.country],
country=options.country,
tmp_dir=options.tmp_dir,
no_geo=options.no_geo,
timeout=options.timeout,
debug=options.debug)
# pull complete report -> Hop data plus meta info about the network
report = traceroute.get_report()
if options.webhook != "":
try:
result = post_result(options.webhook, report, options.timeout)
print "Webhook POST Result: {}".format(result)
except Exception,e:
print "Provided webhook {0} is invalid. Message was: {1}".format(options.webhook, e)
else:
print(json.dumps(report, indent=4))
return 0
if __name__ == '__main__':
sys.exit(main())