Files
2026-05-23 21:58:06 -07:00

368 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""World-map visualization for proxy chains.
Renders hop arcs over the bundled neon world map image. Falls back to a
drawn landmass map if the image file is absent.
"""
from __future__ import annotations
import ipaddress
import logging
import math
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from PIL import Image, ImageDraw, ImageFilter, 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 palette
_BG = "#070713"
_GRID = "#1a1f3a"
_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"
def _map_img_path() -> Path:
"""Locate world_map.png whether running from source or a frozen PyInstaller bundle."""
import sys
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "proxy_chain_manager" / "world_map.png"
return Path(__file__).resolve().parent / "world_map.png"
_cached_map: Image.Image | None = None
def _load_world_map(width: int, height: int) -> Image.Image | None:
"""Return the neon world map resized to (width × height), or None if unavailable."""
global _cached_map
try:
if _cached_map is None:
_cached_map = Image.open(_map_img_path()).convert("RGB")
return _cached_map.resize((width, height), _RESAMPLE)
except Exception as exc:
log.debug("world_map load failed: %s", exc)
return None
@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
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_str = (exit_ip or "").strip()
if exit_str and exit_str != "":
if (
exit_intel
and exit_intel.ok
and exit_intel.ip == exit_str
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_str, 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 = 32,
) -> 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) -> tuple[int, int, int]:
table = {
"healthy": (0, 229, 255),
"connecting": (255, 208, 0),
"dead": (255, 42, 109),
"idle": (48, 82, 255),
}
return table.get(status, (48, 82, 255))
def _node_color(role: str) -> tuple[int, int, int]:
table = {"you": (0, 255, 156), "hop": (0, 229, 255), "exit": (255, 208, 0)}
return table.get(role, (0, 229, 255))
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _draw_glow_line(
draw: ImageDraw.ImageDraw,
pts: list[tuple[float, float]],
color: tuple[int, int, int],
) -> None:
"""Draw neon glow arc: fat dim outer halo → bright core line."""
if len(pts) < 2:
return
r, g, b = color
# outer halo — wider, dim
halo = (r // 4, g // 4, b // 4)
draw.line(pts, fill=halo, width=7)
# mid glow
mid = (r // 2, g // 2, b // 2)
draw.line(pts, fill=mid, width=4)
# bright core
draw.line(pts, fill=color, width=2)
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:
"""Fallback minimal continent silhouettes."""
blobs: list[list[tuple[float, float]]] = [
[(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)],
[(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)],
[(-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)],
[(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)],
[(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 = 12,
) -> Image.Image:
"""Render chain hops over the neon world map (or a drawn fallback)."""
w = max(320, width)
h = max(120, height)
# ── background ──────────────────────────────────────────────────────────
world = _load_world_map(w, h)
if world is not None:
# Slightly darken so overlaid lines pop
bg = Image.new("RGB", (w, h), (0, 0, 0))
img = Image.blend(world, bg, alpha=0.22)
else:
img = Image.new("RGB", (w, h), _BG)
draw_bg = ImageDraw.Draw(img)
_draw_landmasses(draw_bg, w, h, margin)
# grid
for lat in range(-60, 91, 30):
y = _project(lat, 0, w, h, margin)[1]
draw_bg.line([(margin, y), (w - margin, y)], fill=_GRID, width=1)
for lon in range(-150, 181, 30):
x = _project(0, lon, w, h, margin)[0]
draw_bg.line([(x, margin), (x, h - margin)], fill=_GRID, width=1)
draw = ImageDraw.Draw(img)
if len(points) < 2:
font = _load_font(12)
msg = "Start the chain to plot hops on the map." if not points else "Need 2+ geo points to draw path."
# drop shadow
draw.text((margin + 1, h // 2 - 7), msg, fill=(0, 0, 0), font=font)
draw.text((margin, h // 2 - 8), msg, fill=_hex_to_rgb(_LABEL_DIM), font=font)
return img
line_rgb = _line_color(status)
projected = [_project(p.lat, p.lon, w, h, margin) for p in points]
# ── arc lines between consecutive hops ─────────────────────────────────
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, w, h, margin) for lat, lon in arc]
_draw_glow_line(draw, arc_xy, line_rgb)
# ── nodes ───────────────────────────────────────────────────────────────
label_font = _load_font(11)
detail_font = _load_font(9)
for pt, (x, y) in zip(points, projected):
nc = _node_color(pt.role)
r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5)
# outer glow ring
glow = (nc[0] // 3, nc[1] // 3, nc[2] // 3)
draw.ellipse((x - r - 4, y - r - 4, x + r + 4, y + r + 4), fill=glow)
# filled node with dark center
draw.ellipse((x - r, y - r, x + r, y + r), fill=nc)
draw.ellipse((x - r + 2, y - r + 2, x + r - 2, y + r - 2), fill=(5, 5, 20))
tx, ty = int(x + r + 5), int(y - 8)
# text drop-shadow
draw.text((tx + 1, ty + 1), pt.label, fill=(0, 0, 0), font=label_font)
draw.text((tx, ty), pt.label, fill=_hex_to_rgb(_LABEL), font=label_font)
if pt.detail:
draw.text((tx, ty + 12), pt.detail[:30], fill=_hex_to_rgb(_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."""
return pil_img.resize((max(320, int(width)), max(120, int(height))), _RESAMPLE)