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:
Umed
2026-08-09 21:03:02 -04:00
committed by GitHub
co-authored by Umedjon Bazarov
parent 443056892e
commit 0733efd186
50 changed files with 6125 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
"""Parse share links (vless / vmess / ss / socks5 / sn) into server dicts.
The output dict shape matches `backend.models.server.parse_server` so it can be
fed straight into VpnService.add_server.
"""
from __future__ import annotations
import base64
import binascii
import json
import re
import struct
import urllib.parse as urlparse
import uuid
import zlib
def _b64_decode_padded(data: str) -> bytes:
data = data.strip().replace("\n", "").replace("\r", "")
pad = "=" * (-len(data) % 4)
try:
return base64.urlsafe_b64decode(data + pad)
except (binascii.Error, ValueError):
try:
return base64.b64decode(data + pad)
except (binascii.Error, ValueError):
return b""
def parse_subscription_body(body: str) -> list[str]:
"""Return a list of share-link strings from a raw subscription body.
Body may be:
- Base64 of newline-separated share links (most common).
- Plain text with newline-separated share links.
"""
body = body.strip()
if not body:
return []
if "://" not in body:
decoded = _b64_decode_padded(body)
try:
body = decoded.decode("utf-8", errors="replace")
except UnicodeDecodeError:
return []
out: list[str] = []
for line in body.splitlines():
line = line.strip()
if "://" in line:
out.append(line)
return out
def parse_share_link(link: str) -> dict | None:
link = link.strip()
if link.startswith("vless://"):
return _parse_vless(link)
if link.startswith("vmess://"):
return _parse_vmess(link)
if link.startswith("ss://"):
return _parse_ss(link)
if link.startswith("socks5://") or link.startswith("socks://"):
return _parse_socks5(link)
if link.startswith("sn://"):
return _parse_sn(link)
return None
# ---------------------------------------------------------------- sn:// links
#
# sn://<type>?<urlsafe-base64 of zlib(payload)>. The payload is a binary record,
# not JSON: strings are stored raw with the high bit set on their LAST byte
# (so "192.0.2." + 0xb1 reads as "192.0.2.1"), and numbers are 32-bit LE.
#
# This layout was derived by inspection of a working ssh link — no public spec
# was found for it, and it is NOT nekoray's (its repositories contain no "sn://"
# and it has no ssh profile type). The reading was confirmed field by field
# against a real link: port came out as exactly 22 and the user as "root", and
# the owner verified the decoded password character for character.
#
# Because the format is inferred rather than specified, everything here is
# strict: only the "ssh" type is accepted, every field must survive validation,
# and anything unexpected returns None so the caller reports an unsupported link
# instead of silently creating a wrong server. Two trailing fields (an int and
# what looks like a UTF-8 remark) do not fit the scheme and are ignored — the
# name is taken from the host instead.
def _sn_read_string(buf: bytes, i: int) -> tuple[str, int] | None:
"""Read one high-bit-terminated string starting at `i`."""
out = bytearray()
while i < len(buf):
c = buf[i]
i += 1
if c & 0x80:
out.append(c & 0x7F)
try:
return out.decode("utf-8"), i
except UnicodeDecodeError:
return None
out.append(c)
return None # ran off the end without a terminator
def _sn_read_u32(buf: bytes, i: int) -> tuple[int, int] | None:
if i + 4 > len(buf):
return None
return struct.unpack_from("<I", buf, i)[0], i + 4
_SN_PRINTABLE = re.compile(r"^[\x20-\x7e]+$")
def _parse_sn(link: str) -> dict | None:
try:
kind, payload = link[len("sn://"):].split("?", 1)
except ValueError:
return None
if kind != "ssh":
return None # only type verified against a real link
raw = _b64_decode_padded(payload)
if not raw:
return None
try:
buf = zlib.decompress(raw)
except zlib.error:
return None
i = 4 # leading u32, always 0 in the sample; purpose unknown
host_r = _sn_read_string(buf, i)
if not host_r:
return None
host, i = host_r
port_r = _sn_read_u32(buf, i)
if not port_r:
return None
port, i = port_r
user_r = _sn_read_string(buf, i)
if not user_r:
return None
user, i = user_r
# Unknown u32 between user and password (1 in the sample; possibly an auth
# mode). Not trusted for anything.
skip = _sn_read_u32(buf, i)
if not skip:
return None
i = skip[1]
pw_r = _sn_read_string(buf, i)
if not pw_r:
return None
password, _ = pw_r
if not (0 < port < 65536):
return None
for value in (host, user, password):
if not value or not _SN_PRINTABLE.match(value):
return None
return {
"protocol": "ssh",
"name": host,
"host": host,
"port": port,
"user": user,
"password": password,
}
def _decode_name(fragment: str) -> str:
return urlparse.unquote(fragment or "").strip() or "imported"
def _parse_vless(link: str) -> dict | None:
parsed = urlparse.urlparse(link)
if not parsed.username or not parsed.hostname or not parsed.port:
return None
q = urlparse.parse_qs(parsed.query)
def _q(k: str, default: str = "") -> str:
return (q.get(k, [default]) or [default])[0]
out: dict = {
"name": _decode_name(parsed.fragment),
"protocol": "vless",
"address": parsed.hostname,
"port": int(parsed.port),
"uuid": parsed.username,
"transport": _q("type", "tcp") or "tcp",
}
sec = _q("security", "")
out["security"] = sec if sec in ("tls", "reality", "none") else None
out["tls"] = bool(sec in ("tls", "reality"))
sni = _q("sni") or _q("host")
if sni:
out["sni"] = sni
for src, dst in [
("flow", "flow"),
("fp", "fp"),
("pbk", "pbk"),
("sid", "sid"),
("path", "path"),
("serviceName", "serviceName"),
]:
v = _q(src)
if v:
out[dst] = v
out["id"] = _gen_id("vless", out["address"], out["port"], out["uuid"])
return {k: v for k, v in out.items() if v is not None}
def _parse_vmess(link: str) -> dict | None:
payload = link[len("vmess://"):]
decoded = _b64_decode_padded(payload)
if not decoded:
return None
try:
obj = json.loads(decoded.decode("utf-8", errors="replace"))
except (json.JSONDecodeError, ValueError):
return None
addr = obj.get("add")
port = obj.get("port")
uuid_ = obj.get("id")
if not addr or not port or not uuid_:
return None
out = {
"name": obj.get("ps") or "imported",
"protocol": "vmess",
"address": addr,
"port": int(port),
"uuid": uuid_,
"alterId": int(obj.get("aid") or 0),
"security": obj.get("scy") or "auto",
"transport": obj.get("net") or "tcp",
"tls": (obj.get("tls") == "tls"),
}
if obj.get("sni") or obj.get("host"):
out["sni"] = obj.get("sni") or obj.get("host")
if obj.get("path"):
out["path"] = obj["path"]
if obj.get("host"):
out["host"] = obj["host"]
out["id"] = _gen_id("vmess", out["address"], out["port"], out["uuid"])
return out
def _parse_ss(link: str) -> dict | None:
# Two common forms:
# ss://base64(method:password)@host:port#name
# ss://base64(method:password@host:port)#name
rest = link[len("ss://"):]
frag = ""
if "#" in rest:
rest, frag = rest.split("#", 1)
name = _decode_name(frag)
method: str | None = None
password: str | None = None
host: str | None = None
port: int | None = None
if "@" in rest:
creds_b64, host_part = rest.rsplit("@", 1)
creds = _b64_decode_padded(creds_b64).decode("utf-8", errors="replace")
if ":" in creds:
method, password = creds.split(":", 1)
if ":" in host_part:
h, p = host_part.rsplit(":", 1)
host = h
try:
port = int(p)
except ValueError:
pass
else:
whole = _b64_decode_padded(rest).decode("utf-8", errors="replace")
m = re.match(r"^([^:]+):([^@]+)@([^:]+):(\d+)$", whole)
if m:
method, password, host, port = m.group(1), m.group(2), m.group(3), int(m.group(4))
if not method or not password or not host or not port:
return None
return {
"id": _gen_id("ss", host, port, password),
"name": name,
"protocol": "shadowsocks",
"address": host,
"port": port,
"method": method,
"password": password,
}
def _parse_socks5(link: str) -> dict | None:
parsed = urlparse.urlparse(link)
if not parsed.hostname or not parsed.port:
return None
return {
"id": _gen_id("socks5", parsed.hostname, parsed.port, parsed.username or ""),
"name": _decode_name(parsed.fragment),
"protocol": "socks5",
"host": parsed.hostname,
"port": int(parsed.port),
"username": parsed.username,
"password": parsed.password,
}
def _gen_id(proto: str, host: str, port: int, secret: str) -> str:
h = f"{proto}|{host}|{port}|{secret}".encode("utf-8")
return uuid.uuid5(uuid.NAMESPACE_URL, h.decode("utf-8")).hex[:12]