Skip to content

Instantly share code, notes, and snippets.

@VaiTon
Created June 15, 2025 18:53
Show Gist options
  • Save VaiTon/c2595a164e1fd29286eef8ab954696d0 to your computer and use it in GitHub Desktop.
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.
#!/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