Files
community-plugins/ruh-vpn/backend/geoip.py
T
0733efd186 Add umedbazarov/ruh-vpn: VPN/proxy manager for sing-box (#304)
* Add umedbazarov/ruh-vpn: VPN/proxy manager for sing-box

New community plugin: bar widget, panel, service and control-center
shortcut for managing SSH, VLESS, VMess, Shadowsocks and SOCKS5
connections through sing-box, with routing presets, custom rules,
system-proxy/TUN modes and a kill switch. The bundled Python backend
serves a loopback control API protected by a per-launch bearer token.

* Address review: sanitize kill-switch ruleset, scope TUN capability, fix mux error path, disclose DNS

- kill switch: only pre-resolved, canonicalized literal IPs enter the nft
  ruleset; domains are resolved first and anything unparseable is dropped,
  so subscription-supplied addresses can no longer inject nft syntax
- TUN: CAP_NET_ADMIN is granted to a plugin-private copy of sing-box in a
  0700 directory instead of the shared system binary; the copy is refreshed
  (clearing the cap) when the system binary changes, and the legacy grant
  on the shared binary is removed in the same polkit prompt
- fix NameError in the mux startup failure path (undefined mux_name) that
  hid the log tail and skipped teardown
- README: disclose plain-UDP DNS endpoints (8.8.8.8 via tunnel, 223.5.5.5
  direct in rules mode) alongside the TUN DoH endpoint

---------

Co-authored-by: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com>
2026-08-09 21:03:02 -04:00

83 lines
2.8 KiB
Python

"""Resolve a server's country code, so the UI can show a flag.
Nothing else in the backend knows a server's country: the models simply accept
the extra key. The lookup is best-effort and always optional — a server with no
country just shows no flag, exactly as before.
Privacy: this asks a third party (api.country.is) "which country is this IP in",
which discloses the address of the user's own VPN server to that service. Hence
the `geoip_country` plugin setting, which service.luau forwards as
RUH_VPN_GEOIP so it can be turned off. The endpoint is HTTPS and returns
only {"ip": ..., "country": ...}; an offline answer isn't possible here — no
GeoIP database is installed (no *.mmdb) and sing-box's .srs rulesets only cover
specific countries (cn/ir).
"""
from __future__ import annotations
import asyncio
import ipaddress
import os
import re
# Forwarded by service.luau from the geoip_country setting.
ENABLED = os.environ.get("RUH_VPN_GEOIP", "1").lower() not in ("0", "false", "no")
try:
import aiohttp
except ImportError: # pragma: no cover - matches monitoring/health.py's guard
aiohttp = None # type: ignore
LOOKUP_URL = "https://api.country.is/{ip}"
TIMEOUT_SEC = 6
_CC_RE = re.compile(r"^[A-Za-z]{2}$")
async def _resolve_ip(host: str) -> str | None:
"""Return `host` if it is already an IP, else its first A/AAAA record."""
try:
ipaddress.ip_address(host)
return host
except ValueError:
pass
try:
loop = asyncio.get_running_loop()
infos = await asyncio.wait_for(
loop.getaddrinfo(host, None), timeout=TIMEOUT_SEC
)
except (OSError, asyncio.TimeoutError):
return None
return infos[0][4][0] if infos else None
async def lookup_country(host: str) -> str | None:
"""Best-effort ISO-3166 alpha-2 (lowercase) for `host`. None on any failure.
Never raises: a missing flag must not be able to fail an AddServer.
"""
if not host or aiohttp is None:
return None
ip = await _resolve_ip(host.strip())
if not ip:
return None
# A private address has no country, and asking would leak nothing useful.
try:
if not ipaddress.ip_address(ip).is_global:
return None
except ValueError:
return None
try:
timeout = aiohttp.ClientTimeout(total=TIMEOUT_SEC)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(LOOKUP_URL.format(ip=ip)) as resp:
if resp.status != 200:
return None
data = await resp.json(content_type=None)
except Exception:
return None
cc = (data or {}).get("country") if isinstance(data, dict) else None
if isinstance(cc, str) and _CC_RE.match(cc):
return cc.lower()
return None