Plots YOU through each hop to EXIT on an equirectangular map with great-circle connectors, extends exit intel with lat/lon, and auto-refreshes on chain rotation. Co-authored-by: Cursor <cursoragent@cursor.com>
304 lines
9.8 KiB
Python
304 lines
9.8 KiB
Python
"""World-map visualization for proxy chains (Pillow + equirectangular projection)."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import logging
|
|
import math
|
|
import socket
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
from urllib.parse import urlparse
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
try:
|
|
_RESAMPLE = Image.Resampling.LANCZOS
|
|
except AttributeError:
|
|
_RESAMPLE = Image.LANCZOS # type: ignore[attr-defined]
|
|
|
|
from .config import normalize_proxy_url, redact_proxy_url
|
|
from .exit_intel import ExitIntel, fetch_ip_geo
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
ChainStatus = Literal["healthy", "connecting", "dead", "idle"]
|
|
|
|
# Theme colors (match app.py palette)
|
|
_BG = "#070713"
|
|
_GRID = "#141432"
|
|
_LAND = "#12182e"
|
|
_LAND_EDGE = "#1e2a4a"
|
|
_LINE_HEALTHY = "#00e5ff"
|
|
_LINE_CONNECTING = "#ffd000"
|
|
_LINE_DEAD = "#ff2a6d"
|
|
_LINE_IDLE = "#3052ff"
|
|
_YOU = "#00ff9c"
|
|
_HOP = "#00e5ff"
|
|
_EXIT = "#ffd000"
|
|
_LABEL = "#eaf2ff"
|
|
_LABEL_DIM = "#7a8aab"
|
|
_UNKNOWN = "#2a2f4a"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MapPoint:
|
|
role: Literal["you", "hop", "exit"]
|
|
label: str
|
|
lat: float
|
|
lon: float
|
|
detail: str = ""
|
|
hop_index: int = -1
|
|
|
|
|
|
def extract_hop_host(proxy_url: str) -> str | None:
|
|
"""Return hostname or IP from a proxy URL."""
|
|
p = urlparse(normalize_proxy_url(proxy_url))
|
|
host = (p.hostname or "").strip()
|
|
return host or None
|
|
|
|
|
|
def resolve_host_ip(host: str, timeout_seconds: float = 4.0) -> str | None:
|
|
"""Resolve host to IPv4/IPv6 string. Never raises."""
|
|
h = (host or "").strip()
|
|
if not h:
|
|
return None
|
|
try:
|
|
ipaddress.ip_address(h)
|
|
return h
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
prev = socket.getdefaulttimeout()
|
|
socket.setdefaulttimeout(max(1.0, float(timeout_seconds)))
|
|
try:
|
|
infos = socket.getaddrinfo(h, None, type=socket.SOCK_STREAM)
|
|
finally:
|
|
socket.setdefaulttimeout(prev)
|
|
for info in infos:
|
|
addr = info[4][0]
|
|
try:
|
|
ipaddress.ip_address(addr)
|
|
return addr
|
|
except ValueError:
|
|
continue
|
|
except Exception as e:
|
|
log.debug("resolve_host_ip(%s): %s", h, e)
|
|
return None
|
|
|
|
|
|
def _loc_label(intel: ExitIntel) -> str:
|
|
bits = [b for b in (intel.city, intel.region, intel.country) if b]
|
|
return ", ".join(bits) if bits else intel.country_code or intel.ip or "—"
|
|
|
|
|
|
def _intel_to_point(
|
|
role: Literal["you", "hop", "exit"],
|
|
label: str,
|
|
intel: ExitIntel,
|
|
*,
|
|
hop_index: int = -1,
|
|
) -> MapPoint | None:
|
|
if not intel.ok or intel.lat is None or intel.lon is None:
|
|
return None
|
|
return MapPoint(
|
|
role=role,
|
|
label=label,
|
|
lat=float(intel.lat),
|
|
lon=float(intel.lon),
|
|
detail=_loc_label(intel),
|
|
hop_index=hop_index,
|
|
)
|
|
|
|
|
|
def build_chain_map_points(
|
|
hops: list[str],
|
|
*,
|
|
direct_ip: str = "",
|
|
exit_ip: str = "",
|
|
exit_intel: ExitIntel | None = None,
|
|
timeout_seconds: float = 8.0,
|
|
) -> list[MapPoint]:
|
|
"""Resolve geo for YOU → hop₁ → … → EXIT. Never raises."""
|
|
points: list[MapPoint] = []
|
|
|
|
origin_ip = (direct_ip or "").strip()
|
|
if origin_ip and origin_ip != "—":
|
|
you = _intel_to_point("you", "YOU", fetch_ip_geo(origin_ip, timeout_seconds))
|
|
if you:
|
|
points.append(you)
|
|
|
|
for i, hop in enumerate(hops):
|
|
host = extract_hop_host(hop)
|
|
if not host:
|
|
continue
|
|
ip = resolve_host_ip(host, timeout_seconds=min(4.0, timeout_seconds))
|
|
if not ip:
|
|
continue
|
|
short = redact_proxy_url(hop)
|
|
if len(short) > 22:
|
|
short = short[:20] + "…"
|
|
intel = fetch_ip_geo(ip, timeout_seconds)
|
|
pt = _intel_to_point("hop", f"H{i + 1}", intel, hop_index=i)
|
|
if pt:
|
|
points.append(pt)
|
|
|
|
exit = (exit_ip or "").strip()
|
|
if exit and exit != "—":
|
|
if (
|
|
exit_intel
|
|
and exit_intel.ok
|
|
and exit_intel.ip == exit
|
|
and exit_intel.lat is not None
|
|
and exit_intel.lon is not None
|
|
):
|
|
pt = _intel_to_point("exit", "EXIT", exit_intel)
|
|
else:
|
|
pt = _intel_to_point("exit", "EXIT", fetch_ip_geo(exit, timeout_seconds))
|
|
if pt:
|
|
points.append(pt)
|
|
|
|
return points
|
|
|
|
|
|
def _project(lat: float, lon: float, width: int, height: int, margin: int) -> tuple[float, float]:
|
|
inner_w = max(1, width - margin * 2)
|
|
inner_h = max(1, height - margin * 2)
|
|
x = margin + (float(lon) + 180.0) / 360.0 * inner_w
|
|
y = margin + (90.0 - float(lat)) / 180.0 * inner_h
|
|
return x, y
|
|
|
|
|
|
def _great_circle_points(
|
|
lat1: float,
|
|
lon1: float,
|
|
lat2: float,
|
|
lon2: float,
|
|
steps: int = 24,
|
|
) -> list[tuple[float, float]]:
|
|
"""Interpolate along a great circle for curved hop arcs."""
|
|
phi1, lam1 = math.radians(lat1), math.radians(lon1)
|
|
phi2, lam2 = math.radians(lat2), math.radians(lon2)
|
|
d = 2 * math.asin(
|
|
math.sqrt(
|
|
math.sin((phi2 - phi1) / 2) ** 2
|
|
+ math.cos(phi1) * math.cos(phi2) * math.sin((lam2 - lam1) / 2) ** 2
|
|
)
|
|
)
|
|
if d < 1e-9:
|
|
return [(lat1, lon1), (lat2, lon2)]
|
|
out: list[tuple[float, float]] = []
|
|
for i in range(steps + 1):
|
|
f = i / steps
|
|
a = math.sin((1 - f) * d) / math.sin(d)
|
|
b = math.sin(f * d) / math.sin(d)
|
|
x = a * math.cos(phi1) * math.cos(lam1) + b * math.cos(phi2) * math.cos(lam2)
|
|
y = a * math.cos(phi1) * math.sin(lam1) + b * math.cos(phi2) * math.sin(lam2)
|
|
z = a * math.sin(phi1) + b * math.sin(phi2)
|
|
out.append((math.degrees(math.atan2(z, math.sqrt(x * x + y * y))), math.degrees(math.atan2(y, x))))
|
|
return out
|
|
|
|
|
|
def _line_color(status: ChainStatus) -> str:
|
|
return {
|
|
"healthy": _LINE_HEALTHY,
|
|
"connecting": _LINE_CONNECTING,
|
|
"dead": _LINE_DEAD,
|
|
"idle": _LINE_IDLE,
|
|
}.get(status, _LINE_IDLE)
|
|
|
|
|
|
def _node_color(role: str) -> str:
|
|
return {"you": _YOU, "hop": _HOP, "exit": _EXIT}.get(role, _HOP)
|
|
|
|
|
|
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
for name in ("segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"):
|
|
try:
|
|
return ImageFont.truetype(name, size)
|
|
except OSError:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _draw_landmasses(draw: ImageDraw.ImageDraw, width: int, height: int, margin: int) -> None:
|
|
"""Minimal continent silhouettes — stylized, not survey-accurate."""
|
|
blobs: list[list[tuple[float, float]]] = [
|
|
# North America
|
|
[(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)],
|
|
# South America
|
|
[(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)],
|
|
# Europe / Africa
|
|
[(-10, 72), (30, 70), (40, 55), (35, 30), (20, 5), (-5, 5), (-18, 28), (-10, 72)],
|
|
[(15, 35), (35, 32), (50, 12), (42, -5), (18, -35), (15, 35)],
|
|
# Asia
|
|
[(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)],
|
|
# Australia
|
|
[(115, -12), (135, -12), (150, -25), (145, -38), (115, -38), (115, -12)],
|
|
]
|
|
for poly in blobs:
|
|
pts = [_project(lat, lon, width, height, margin) for lat, lon in poly]
|
|
draw.polygon(pts, fill=_LAND, outline=_LAND_EDGE)
|
|
|
|
|
|
def render_chain_map(
|
|
points: list[MapPoint],
|
|
*,
|
|
width: int = 960,
|
|
height: int = 220,
|
|
status: ChainStatus = "idle",
|
|
margin: int = 18,
|
|
) -> Image.Image:
|
|
"""Render chain hops on a dark equirectangular world map."""
|
|
img = Image.new("RGB", (max(320, width), max(120, height)), _BG)
|
|
draw = ImageDraw.Draw(img)
|
|
_draw_landmasses(draw, width, height, margin)
|
|
|
|
# Lat/lon grid
|
|
for lat in range(-60, 91, 30):
|
|
y = _project(lat, 0, width, height, margin)[1]
|
|
draw.line([(margin, y), (width - margin, y)], fill=_GRID, width=1)
|
|
for lon in range(-150, 181, 30):
|
|
x = _project(0, lon, width, height, margin)[0]
|
|
draw.line([(x, margin), (x, height - margin)], fill=_GRID, width=1)
|
|
|
|
if len(points) < 2:
|
|
font = _load_font(13)
|
|
msg = "Start the chain to plot hops on the map." if not points else "Need 2+ geo points to draw path."
|
|
draw.text((margin, height // 2 - 8), msg, fill=_LABEL_DIM, font=font)
|
|
return img
|
|
|
|
line_c = _line_color(status)
|
|
projected = [_project(p.lat, p.lon, width, height, margin) for p in points]
|
|
|
|
# Glow underlay
|
|
for i in range(len(projected) - 1):
|
|
arc = _great_circle_points(points[i].lat, points[i].lon, points[i + 1].lat, points[i + 1].lon)
|
|
arc_xy = [_project(lat, lon, width, height, margin) for lat, lon in arc]
|
|
draw.line(arc_xy, fill=line_c, width=5)
|
|
for i in range(len(projected) - 1):
|
|
arc = _great_circle_points(points[i].lat, points[i].lon, points[i + 1].lat, points[i + 1].lon)
|
|
arc_xy = [_project(lat, lon, width, height, margin) for lat, lon in arc]
|
|
draw.line(arc_xy, fill=line_c, width=2)
|
|
|
|
label_font = _load_font(11)
|
|
detail_font = _load_font(9)
|
|
for pt, (x, y) in zip(points, projected):
|
|
color = _node_color(pt.role)
|
|
r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5)
|
|
draw.ellipse((x - r - 2, y - r - 2, x + r + 2, y + r + 2), fill=color)
|
|
draw.ellipse((x - r, y - r, x + r, y + r), fill=_BG, outline=color, width=2)
|
|
tx, ty = x + 10, y - 14
|
|
draw.text((tx + 1, ty + 1), pt.label, fill="#000000", font=label_font)
|
|
draw.text((tx, ty), pt.label, fill=_LABEL, font=label_font)
|
|
if pt.detail:
|
|
draw.text((tx, ty + 13), pt.detail[:28], fill=_LABEL_DIM, font=detail_font)
|
|
|
|
return img
|
|
|
|
|
|
def resize_map_display(pil_img: Image.Image, width: int, height: int) -> Image.Image:
|
|
"""Resize rendered map for the GUI widget."""
|
|
w = max(320, int(width))
|
|
h = max(120, int(height))
|
|
return pil_img.resize((w, h), _RESAMPLE)
|