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>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user