* 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>
158 lines
5.3 KiB
Python
158 lines
5.3 KiB
Python
"""Fetch subscription URLs, parse, import into VpnService."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
import aiohttp
|
|
|
|
from backend.models.server import parse_server, server_to_dict
|
|
from backend.storage.subscriptions import load_subscriptions, save_subscriptions
|
|
from backend.subscription.parsers import parse_share_link, parse_subscription_body
|
|
|
|
if TYPE_CHECKING:
|
|
from backend.service.vpn_service import VpnService
|
|
|
|
AUTO_UPDATE_INTERVAL_SEC = 24 * 3600
|
|
FETCH_TIMEOUT_SEC = 30
|
|
USER_AGENT = "ruh-vpn/0.1 (subscription-fetcher)"
|
|
|
|
|
|
class SubscriptionManager:
|
|
def __init__(self, service: "VpnService") -> None:
|
|
self._svc = service
|
|
self._task: Optional[asyncio.Task] = None
|
|
self._subs: list[dict] = []
|
|
|
|
async def bootstrap(self) -> None:
|
|
self._subs = await load_subscriptions()
|
|
|
|
def start_auto_update(self) -> None:
|
|
if self._task and not self._task.done():
|
|
return
|
|
self._task = asyncio.create_task(self._auto_loop())
|
|
|
|
async def stop(self) -> None:
|
|
if self._task is None:
|
|
return
|
|
self._task.cancel()
|
|
try:
|
|
await self._task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._task = None
|
|
|
|
async def list_subs(self) -> list[dict]:
|
|
return [dict(s) for s in self._subs]
|
|
|
|
async def add(self, url: str, name: str = "") -> bool:
|
|
url = url.strip()
|
|
if not url:
|
|
return False
|
|
if any(s["url"] == url for s in self._subs):
|
|
return False
|
|
entry = {
|
|
"url": url,
|
|
"name": name or url,
|
|
"last_updated": 0,
|
|
"server_count": 0,
|
|
}
|
|
self._subs.append(entry)
|
|
await save_subscriptions(self._subs)
|
|
return True
|
|
|
|
async def remove(self, url: str) -> bool:
|
|
before = len(self._subs)
|
|
self._subs = [s for s in self._subs if s["url"] != url]
|
|
if len(self._subs) == before:
|
|
return False
|
|
await save_subscriptions(self._subs)
|
|
return True
|
|
|
|
async def update(self, url: str) -> int:
|
|
"""Fetch a single subscription URL and import its servers. Returns count."""
|
|
for s in self._subs:
|
|
if s["url"] == url:
|
|
return await self._fetch_and_import(s)
|
|
return 0
|
|
|
|
async def update_all(self) -> int:
|
|
total = 0
|
|
for s in list(self._subs):
|
|
total += await self._fetch_and_import(s)
|
|
return total
|
|
|
|
async def _fetch_and_import(self, sub: dict) -> int:
|
|
try:
|
|
body = await self._fetch(sub["url"])
|
|
except Exception as exc:
|
|
self._svc._log("error", f"subscription fetch failed for {sub['url']}: {exc}")
|
|
return 0
|
|
links = parse_subscription_body(body)
|
|
imported = 0
|
|
existing_keys = {self._server_key(s) for s in self._svc.state.servers}
|
|
for link in links:
|
|
entry = parse_share_link(link)
|
|
if not entry:
|
|
continue
|
|
try:
|
|
server = parse_server(entry)
|
|
except Exception:
|
|
continue
|
|
key = self._server_key(server)
|
|
if key in existing_keys:
|
|
# update existing entry's fields by replacing with new id
|
|
existing = next(
|
|
(s for s in self._svc.state.servers if self._server_key(s) == key), None
|
|
)
|
|
if existing:
|
|
entry["id"] = existing.id
|
|
await self._svc.update_server(entry)
|
|
continue
|
|
await self._svc.add_server(entry)
|
|
existing_keys.add(key)
|
|
imported += 1
|
|
sub["last_updated"] = int(time.time())
|
|
sub["server_count"] = len(links)
|
|
await save_subscriptions(self._subs)
|
|
return imported
|
|
|
|
@staticmethod
|
|
def _server_key(server) -> tuple:
|
|
if isinstance(server, dict):
|
|
proto = server.get("protocol", "")
|
|
addr = server.get("address") or server.get("host") or ""
|
|
port = server.get("port")
|
|
secret = server.get("uuid") or server.get("password") or ""
|
|
return (proto, addr, port, secret)
|
|
proto = getattr(server, "protocol", "")
|
|
addr = getattr(server, "address", None) or getattr(server, "host", None) or ""
|
|
port = getattr(server, "port", None)
|
|
secret = (
|
|
getattr(server, "uuid", None)
|
|
or getattr(server, "password", None)
|
|
or ""
|
|
)
|
|
return (proto, addr, port, secret)
|
|
|
|
async def _fetch(self, url: str) -> str:
|
|
timeout = aiohttp.ClientTimeout(total=FETCH_TIMEOUT_SEC)
|
|
headers = {"User-Agent": USER_AGENT}
|
|
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
|
|
async with session.get(url) as resp:
|
|
resp.raise_for_status()
|
|
return await resp.text(errors="replace")
|
|
|
|
async def _auto_loop(self) -> None:
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(AUTO_UPDATE_INTERVAL_SEC)
|
|
try:
|
|
await self.update_all()
|
|
except Exception as exc:
|
|
self._svc._log("error", f"auto-update failed: {exc}")
|
|
except asyncio.CancelledError:
|
|
return
|