#!/usr/bin/env python3 import argparse, gzip, ipaddress, re, sys, time, socket, zlib from pathlib import Path from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError from html.parser import HTMLParser OUTDIR = Path('/data/media/bgp') HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Encoding': 'gzip, deflate', 'Referer': 'https://bgp.tools/', 'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7', 'Upgrade-Insecure-Requests': '1', 'Cache-Control': 'max-age=0', 'Sec-Ch-Ua': '"Chromium";v="122", "Google Chrome";v="122", "Not=A?Brand";v="99"', 'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Platform': '"Windows"', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', 'Sec-Fetch-Dest': 'document', 'Dnt': '1', 'Sec-Gpc': '1', 'Pragma': 'no-cache', } class TextParser(HTMLParser): def __init__(self): super().__init__() self.parts=[] def handle_data(self, data): if data and data.strip(): self.parts.append(data.strip()) def text(self): return '\n'.join(self.parts) def resolve_target(s): raw = s.strip() m = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', raw) if m: try: return ipaddress.IPv4Address(m.group(1)), None except Exception: raise SystemExit('ERROR: invalid IPv4') # Treat as domain/hostname: strip scheme/path/port and resolve A records. host = re.sub(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', '', raw).split('/')[0].split('?')[0].strip('[]') if '@' in host: host = host.rsplit('@', 1)[-1] if ':' in host and host.count(':') == 1: host = host.rsplit(':', 1)[0] host = host.strip().rstrip('.') if not host or not re.match(r'^[A-Za-z0-9.-]+$', host): raise SystemExit('ERROR: no IPv4 or valid domain found') try: infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM) except socket.gaierror as e: raise SystemExit(f'ERROR: failed to resolve domain {host}: {e}') ips = [] for info in infos: addr = info[4][0] if addr not in ips: ips.append(addr) if not ips: raise SystemExit(f'ERROR: no IPv4 A record found for {host}') return ipaddress.IPv4Address(ips[0]), host def prefixes(ip): # Prefer real visible prefixes from bgp.tools search, sorted by highest visibility. # Tie-breaker: more-specific first, then bgp.tools row order. Blind /24 can be wrong. real = search_prefixes(ip) if real: return [net for net, _visibility, _asn in real] p24 = ipaddress.IPv4Network(f'{ip}/24', strict=False) p23 = ipaddress.IPv4Network(f'{ip}/23', strict=False) res=[p24] if p23 != p24: res.append(p23) return res def search_prefixes(ip): url=f'https://bgp.tools/search?q={ip}' try: html,_=fetch(url) except Exception: return [] rows=[] text=html.decode('utf-8','ignore') visibility_rank={'high': 3, 'medium': 2, 'low': 1} seen=set() for rm in re.finditer(r']*>(.*?)', text, re.I | re.S): row=rm.group(1) pm=re.search(r'/prefix/(\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})', row) if not pm: continue try: net=ipaddress.IPv4Network(pm.group(1), strict=False) except Exception: continue if ip not in net or net in seen: continue seen.add(net) am=re.search(r'/as/(\d+)', row, re.I) asn=f'AS{am.group(1)}' if am else '' cells=re.findall(r']*>(.*?)', row, re.I | re.S) cell_text=[re.sub(r'<[^>]+>', ' ', c).strip() for c in cells] visibility='' for c in reversed(cell_text): lc=re.sub(r'\s+', ' ', c).strip().lower() if lc in visibility_rank: visibility=lc break rows.append((net, visibility, asn, visibility_rank.get(visibility, 0), len(rows))) # bgp.tools search may show route objects (e.g. RADB) without /prefix links or # visibility cells. Include those containing prefixes so /21-/16 announcements # are not missed when the visibility table is absent/noisy. for m in re.finditer(r'\b(\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})\b', text): try: net=ipaddress.IPv4Network(m.group(1), strict=False) except Exception: continue if ip not in net or net in seen: continue seen.add(net) # Unknown visibility ranks below explicit High/Medium/Low rows, but above blind fallback. rows.append((net, 'unknown', '', 0, len(rows))) rows.sort(key=lambda x: (x[3], x[0].prefixlen, -x[4]), reverse=True) return [(net, visibility, asn) for net, visibility, asn, _rank, _idx in rows] def fetch(url, timeout=20): req=Request(url, headers=HEADERS) with urlopen(req, timeout=timeout) as r: data = r.read() enc = (r.headers.get('content-encoding') or '').lower() if enc == 'gzip': data = gzip.decompress(data) elif enc == 'deflate': try: data = zlib.decompress(data) except zlib.error: data = zlib.decompress(data, -zlib.MAX_WBITS) return data, r.headers.get('content-type','') def placeholder(svg: bytes): txt = svg[:20000].decode('utf-8', 'ignore') return 'Not_Visible' in txt and 'in_DFZ' in txt def svg_to_png(svg_path: Path, png_path: Path): # Prefer cairosvg if present, fallback to rsvg-convert, then ImageMagick. try: import cairosvg cairosvg.svg2png(url=str(svg_path), write_to=str(png_path), output_width=2400) return except Exception as e: last=e import subprocess, shutil if shutil.which('rsvg-convert'): subprocess.check_call(['rsvg-convert','-w','2400','-f','png','-o',str(png_path),str(svg_path)]) return if shutil.which('magick'): subprocess.check_call(['magick','-density','300',str(svg_path),'-resize','2400x1800>',str(png_path)]) return raise RuntimeError(f'no SVG converter available; install cairosvg/sharp/librsvg/imagemagick. last={last}') def fetch_bgp(ip, domain=None, outdir=OUTDIR): outdir.mkdir(parents=True, exist_ok=True) tried=[]; ph=None for net in prefixes(ip): pfx=str(net) urlip=pfx.replace('/','_') url=f'https://bgp.tools/pathimg/rt-{urlip}?4c1db184-e649-4491-8b7f-06177bcb4f25&loggedin' tried.append(url) try: data, ctype = fetch(url) except HTTPError as e: if e.code == 404: continue continue except URLError: continue if placeholder(data): ph=pfx; continue stamp=int(time.time()) base=f'bgp-{str(net).replace("/","_")}-{stamp}' svg=outdir/(base+'.svg') png=outdir/(base+'.png') target_safe=re.sub(r'[^A-Za-z0-9_.-]+', '_', str(domain or ip)).strip('_') or 'target' latest=outdir/(f'latest-{target_safe}.png') svg.write_bytes(data) svg_to_png(svg, png) latest.write_bytes(png.read_bytes()) try: svg.unlink() except Exception: pass print(f'OK\nTARGET={domain or ip}\nIP={ip}\nPREFIX={pfx}\nPNG={png}\nLATEST={latest}\nURL=https://bgp.tools/prefix/{pfx}') return 0 if ph: print(f'PLACEHOLDER\nTARGET={domain or ip}\nIP={ip}\nPREFIX={ph}\nURL=https://bgp.tools/prefix/{ph}\nREASON=bgp.tools temporarily returned no path image; please retry once') return 2 pfx=str(prefixes(ip)[0]) print(f'NONE\nTARGET={domain or ip}\nIP={ip}\nPREFIX={pfx}\nURL=https://bgp.tools/prefix/{pfx}\nREASON=no usable BGP path image found') return 3 def tld(domain): parts=domain.split('.') return '.'.join(parts[-2:]) if len(parts)>=2 else domain def fetch_dns(ip, domain=None): for net in prefixes(ip): pfx=str(net) url=f'https://bgp.tools/prefix/{pfx}#dns' try: html,_=fetch(url) except Exception: continue parser=TextParser(); parser.feed(html.decode('utf-8','ignore')) text=parser.text() rows=re.findall(r'(\d{1,3}(?:\.\d{1,3}){3})\s+([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', text) counts={} for _,d in rows: counts[tld(d)]=counts.get(tld(d),0)+1 lines=[f'{a}\t{d}' for a,d in rows if counts.get(tld(d),0)<=2] if lines: print('OK_DNS') print(f'TARGET={domain or ip}\nIP={ip}\nPREFIX={pfx}\nURL={url}') print('DNS_LINES_BEGIN') print('\n'.join(lines[:80])) print('DNS_LINES_END') return 0 print(f'NONE_DNS\nTARGET={domain or ip}\nIP={ip}\nREASON=no DNS records found') return 3 def main(): ap=argparse.ArgumentParser() ap.add_argument('--dns', action='store_true') ap.add_argument('--outdir', default=str(OUTDIR), help='directory for generated BGP images') ap.add_argument('ip') args=ap.parse_args() ip, domain = resolve_target(args.ip) return fetch_dns(ip, domain) if args.dns else fetch_bgp(ip, domain, Path(args.outdir)) if __name__ == '__main__': raise SystemExit(main())