Created
June 15, 2025 18:53
-
-
Save VaiTon/c2595a164e1fd29286eef8ab954696d0 to your computer and use it in GitHub Desktop.
Traces the route to an IP or domain and displays real-time info about each hop using ipinfo.io.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# SPDX-FileCopyrightText: 2025 Eyad Issa | |
# SPDX-License-Identifier: GPL-3.0-or-later | |
""" | |
traceips.py | |
Traces the route to a given IPv4/IPv6 address or hostname and displays information | |
about each hop by querying ipinfo.io. The output is formatted as two columns: | |
the hop IP (or hop number for "no reply" hops) and the associated info. | |
Supports IPv4, IPv6, and domain names. | |
""" | |
import sys | |
import re | |
import subprocess | |
import requests | |
import argparse | |
import shutil | |
IPV4_STRICT_RE = re.compile( | |
r"^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}" | |
r"(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$" | |
) | |
IPV6_RE = re.compile(r"^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$") | |
def check_requirements(): | |
"""Ensure required external commands are available.""" | |
for prog in ["tracepath", "stdbuf"]: | |
if not shutil.which(prog): | |
print( | |
f"Error: Required program '{prog}' not found. Please install it and try again." | |
) | |
sys.exit(1) | |
def get_ipinfo(ip, timeout=5): | |
"""Query ipinfo.io for information about a given IP address.""" | |
try: | |
r = requests.get(f"https://ipinfo.io/{ip}", timeout=timeout) | |
if r.status_code == 200: | |
return r.json() | |
else: | |
print( | |
f"Error: ipinfo.io query failed for {ip} (status code {r.status_code})", | |
file=sys.stderr, | |
) | |
except Exception as e: | |
print(f"Error: ipinfo.io query failed for {ip}: {e}", file=sys.stderr) | |
return {} | |
def resolve_protocol_and_format(target): | |
"""Determine which tracepath command and column width to use.""" | |
if IPV4_STRICT_RE.match(target): | |
return "tracepath", "{:<15}\t{}" | |
elif IPV6_RE.match(target): | |
# Prefer tracepath -6 if available | |
result = subprocess.run( | |
["tracepath", "-6"], stdout=subprocess.PIPE, stderr=subprocess.PIPE | |
) | |
if b"usage" in result.stderr: | |
return "tracepath -6", "{:<20}\t{}" | |
else: | |
return "tracepath6", "{:<20}\t{}" | |
else: | |
# Try to resolve domain to IP (prefer IPv6 if available) | |
try: | |
import socket | |
info = socket.getaddrinfo(target, None) | |
for entry in info: | |
if entry[0] == socket.AF_INET6: | |
return "tracepath -6", "{:<20}\t{}" | |
return "tracepath", "{:<15}\t{}" | |
except Exception: | |
print(f"Error: Could not resolve domain name '{target}' to an IP address.") | |
sys.exit(1) | |
def parse_hop_line(line): | |
"""Extract hop number, hop address, and ms from a tracepath output line.""" | |
m = re.match(r"^\s*(\d+):\s+([^\s]+)", line) | |
if not m: | |
return None, None, None | |
hop_num = m.group(1) | |
hop = m.group(2).strip("[]") | |
ms_match = re.search(r"([0-9.]+ms)", line) | |
ms = ms_match.group(1) if ms_match else "" | |
return hop_num, hop, ms | |
def format_hop_label(hop_num, hop): | |
"""Format the left column for a hop.""" | |
if hop == "no": | |
return f"hop n.{hop_num}" | |
return hop | |
def format_hop_info(hop, ms, timeout=5): | |
"""Format the right column for a hop, querying ipinfo.io if needed.""" | |
if not (IPV4_STRICT_RE.match(hop) or IPV6_RE.match(hop)): | |
return "" | |
info = get_ipinfo(hop, timeout=timeout) | |
if not info: | |
right_str = "No public info available for this hop." | |
elif info.get("bogon"): | |
right_str = "Bogon" | |
else: | |
city = info.get("city", "") | |
region = info.get("region", "") | |
country = info.get("country", "") | |
org = info.get("org", "") | |
if not (city or region or country or org): | |
right_str = "No public info available for this hop." | |
else: | |
right_str = f"{city} | {region} | {country} | {org}" | |
if ms: | |
right_str = f"{right_str} ({ms})" | |
return right_str | |
def format_no_reply_info(ms): | |
"""Format the right column for a 'no reply' hop.""" | |
right_str = "No reply from this hop." | |
if ms: | |
right_str = f"{right_str} ({ms})" | |
return right_str | |
def run_tracepath(trace_cmd, target): | |
cmd = f"stdbuf -oL {trace_cmd} -n {target}" | |
proc = subprocess.Popen( | |
cmd, | |
shell=True, | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
text=True, | |
bufsize=1, | |
) | |
# Yield lines as they are produced | |
if proc.stdout is not None: | |
for line in proc.stdout: | |
yield line.rstrip("\n") | |
proc.stdout.close() | |
proc.wait() | |
if __name__ == "__main__": | |
parser = argparse.ArgumentParser( | |
description="Traces the route to a given IPv4/IPv6 address or hostname and displays information about each hop by querying ipinfo.io." | |
) | |
parser.add_argument( | |
"target", | |
metavar="TARGET", | |
type=str, | |
help="IP address or hostname to trace", | |
) | |
args = parser.parse_args() | |
target = args.target | |
# Validate input: must be valid IPv4, IPv6, or resolvable domain | |
valid = False | |
if IPV4_STRICT_RE.match(target) or IPV6_RE.match(target): | |
valid = True | |
else: | |
try: | |
import socket | |
socket.getaddrinfo(target, None) | |
valid = True | |
except Exception: | |
pass | |
if not valid: | |
print( | |
f"Error: '{target}' is not a valid IPv4, IPv6, or resolvable domain name." | |
) | |
sys.exit(1) | |
trace_cmd, hop_fmt = resolve_protocol_and_format(target) | |
print(f"Tracing route to {target}...") | |
print(hop_fmt.format("Hop", "Info")) | |
for line in run_tracepath(trace_cmd, target): | |
hop_num, hop, ms = parse_hop_line(line) | |
if not hop_num or not hop: | |
continue | |
if hop == "no": | |
left_str = format_hop_label(hop_num, hop) | |
right_str = format_no_reply_info(ms) | |
else: | |
left_str = format_hop_label(hop_num, hop) | |
right_str = format_hop_info(hop, ms) | |
if left_str: | |
print(hop_fmt.format(left_str, right_str)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment