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
+17
View File
@@ -0,0 +1,17 @@
"""Point the backend at a throwaway data dir BEFORE any backend import.
backend.paths reads RUH_VPN_* at import time, so this must run first —
pytest imports conftest before collecting test modules, which guarantees it.
"""
import os
import sys
import tempfile
from pathlib import Path
_tmp = tempfile.mkdtemp(prefix="ruh-vpn-test-")
os.environ["RUH_VPN_DATA_DIR"] = _tmp
os.environ["RUH_VPN_RUNTIME_DIR"] = os.path.join(_tmp, "runtime")
os.environ["RUH_VPN_GEOIP"] = "0"
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+45
View File
@@ -0,0 +1,45 @@
import os
from types import SimpleNamespace
from backend.http.control import ControlServer
def _control(token: str = "right-token", token_file=None) -> ControlServer:
state = SimpleNamespace(
status_listeners=[], server_list_listeners=[], log_listeners=[]
)
service = SimpleNamespace(state=state, add_traffic_listener=lambda cb: None)
return ControlServer(service, token=token, token_file=token_file)
def _request(header: str | None):
headers = {} if header is None else {"Authorization": header}
return SimpleNamespace(headers=headers)
def test_missing_header_rejected():
assert not _control()._authorized(_request(None))
def test_wrong_token_rejected():
assert not _control()._authorized(_request("Bearer wrong"))
def test_wrong_scheme_rejected():
assert not _control()._authorized(_request("Basic right-token"))
def test_correct_token_accepted():
assert _control()._authorized(_request("Bearer right-token"))
def test_empty_configured_token_rejects_everything():
# A backend that somehow starts without a token must fail closed.
assert not _control(token="")._authorized(_request("Bearer "))
def test_token_file_written_0600(tmp_path):
path = tmp_path / "control.token"
_control(token="secret", token_file=path)._publish_token()
assert path.read_text().strip() == "secret"
assert (os.stat(path).st_mode & 0o777) == 0o600
+44
View File
@@ -0,0 +1,44 @@
import json
import shutil
import subprocess
import pytest
from backend.routing.rules import PRESETS
from backend.singbox import config_builder
ALL = list(PRESETS.keys())
def test_rules_config_includes_presets():
cfg = config_builder.build_rules_config(active_presets=ALL)
tags = {rs["tag"] for rs in cfg["route"]["rule_set"]}
expected = {rs["tag"] for p in PRESETS.values() for rs in p["rule_sets"]}
assert expected <= tags
proxy_rules = [r for r in cfg["route"]["rules"] if r.get("outbound") == "proxy" and "rule_set" in r]
assert len(proxy_rules) == len(ALL)
def test_rules_config_dns_covers_domain_rule_sets():
cfg = config_builder.build_rules_config(active_presets=ALL)
dns_rule_sets = [r["rule_set"] for r in cfg["dns"]["rules"] if "rule_set" in r]
flattened = {t for group in dns_rule_sets for t in group}
assert "refilter_domains" in flattened
assert "geosite_noncn" in flattened
assert "geosite_sanctioned" in flattened
@pytest.mark.skipif(shutil.which("sing-box") is None, reason="sing-box not installed")
@pytest.mark.parametrize("presets", [[], ALL])
def test_sing_box_accepts_generated_configs(tmp_path, presets):
for name, cfg in {
"rules": config_builder.build_rules_config(active_presets=presets),
"global": config_builder.build_global_config(),
}.items():
path = tmp_path / f"{name}.json"
path.write_text(json.dumps(cfg))
proc = subprocess.run(
["sing-box", "check", "-c", str(path)],
capture_output=True, text=True, timeout=30,
)
assert proc.returncode == 0, f"{name}: {proc.stderr}"
+64
View File
@@ -0,0 +1,64 @@
"""build_ruleset must never let untrusted text into the nft program.
The ruleset text is executed by nft with root privileges, and server
addresses can come from untrusted subscriptions.
"""
from backend.service.kill_switch import TABLE_NAME, build_ruleset
def test_ipv4_with_port():
rs = build_ruleset(["203.0.113.7"], 443)
assert " ip daddr 203.0.113.7 tcp dport 443 accept\n" in rs
assert f"table inet {TABLE_NAME}" in rs
def test_ipv6_goes_to_ip6_rule():
rs = build_ruleset(["2001:db8::1"], 8443)
assert " ip6 daddr 2001:db8::1 tcp dport 8443 accept\n" in rs
assert "ip daddr 2001:db8::1" not in rs
def test_ip_without_port():
rs = build_ruleset(["203.0.113.7"], None)
assert " ip daddr 203.0.113.7 accept\n" in rs
def test_multiple_ips():
rs = build_ruleset(["203.0.113.7", "2001:db8::1"], 443)
assert "ip daddr 203.0.113.7 tcp dport 443 accept" in rs
assert "ip6 daddr 2001:db8::1 tcp dport 443 accept" in rs
def test_domain_is_dropped():
rs = build_ruleset(["evil.example.com"], 443)
assert "evil.example.com" not in rs
def test_newline_injection_is_dropped():
payload = "1.2.3.4\ndelete table inet filter\n"
rs = build_ruleset([payload], 443)
assert "delete table inet filter" not in rs
# and the payload as a whole must not appear either
assert payload not in rs
def test_non_canonical_ip_is_reemitted_canonically():
rs = build_ruleset(["2001:0DB8:0000:0000:0000:0000:0000:0001"], None)
assert "ip6 daddr 2001:db8::1 accept" in rs
def test_ports_are_coerced_to_int():
rs = build_ruleset(["1.2.3.4"], "443")
assert "tcp dport 443 accept" in rs
rs2 = build_ruleset(None, None, extra_allow_tcp=["11080", 11081])
assert "tcp dport { 11080, 11081 } accept" in rs2
def test_no_server_lines_without_ips():
rs = build_ruleset(None, None)
assert "daddr" not in rs.split("chain input")[0].replace(
"ip daddr 192.168.0.0/16 accept", ""
).replace("ip daddr 10.0.0.0/8 accept", "").replace(
"ip daddr 172.16.0.0/12 accept", ""
)
+39
View File
@@ -0,0 +1,39 @@
from backend.models.server import (
SENSITIVE_FIELDS,
parse_server,
server_to_dict,
server_to_public_dict,
)
def test_public_dict_strips_secrets():
server = parse_server({
"id": "s1", "name": "n", "protocol": "vless",
"address": "example.com", "port": 443,
"uuid": "11111111-2222-3333-4444-555555555555",
})
full = server_to_dict(server)
public = server_to_public_dict(server)
assert full["uuid"]
for key in SENSITIVE_FIELDS:
assert key not in public
assert public["address"] == "example.com"
assert public["port"] == 443
def test_public_dict_ssh_password():
server = parse_server({
"id": "s2", "name": "n", "protocol": "ssh",
"host": "example.com", "port": 22, "user": "root", "password": "pw",
})
public = server_to_public_dict(server)
assert "password" not in public
assert public["user"] == "root"
def test_socks_alias():
server = parse_server({
"id": "s3", "name": "n", "protocol": "socks",
"host": "example.com", "port": 1080,
})
assert server.protocol == "socks5"
+41
View File
@@ -0,0 +1,41 @@
import base64
from backend.subscription.parsers import parse_share_link
def test_vless_link():
link = (
"vless://11111111-2222-3333-4444-555555555555@example.com:443"
"?type=ws&security=tls&sni=cdn.example.com&path=%2Fws#My%20VLESS"
)
data = parse_share_link(link)
assert data is not None
assert data["protocol"] == "vless"
assert data["address"] == "example.com"
assert data["port"] == 443
assert data["uuid"] == "11111111-2222-3333-4444-555555555555"
assert data["transport"] == "ws"
assert data["security"] == "tls"
def test_ss_link():
userinfo = base64.urlsafe_b64encode(b"aes-256-gcm:secretpw").decode().rstrip("=")
data = parse_share_link(f"ss://{userinfo}@example.com:8388#SS")
assert data is not None
assert data["protocol"] == "shadowsocks"
assert data["method"] == "aes-256-gcm"
assert data["password"] == "secretpw"
assert data["port"] == 8388
def test_socks5_link():
data = parse_share_link("socks5://user:pw@example.com:1080#S5")
assert data is not None
assert data["protocol"] == "socks5"
assert data["port"] == 1080
def test_unsupported_link():
assert parse_share_link("trojan://whatever@example.com:443") is None
assert parse_share_link("not a link") is None
assert parse_share_link("") is None
+27
View File
@@ -0,0 +1,27 @@
import asyncio
import os
from backend.models.server import parse_server
from backend.storage.persistence import load_servers, save_servers
from backend.paths import DATA_DIR
def test_servers_round_trip_with_private_permissions():
server = parse_server({
"id": "p1", "name": "n", "protocol": "ssh",
"host": "example.com", "port": 22, "user": "root", "password": "pw",
})
async def run():
await save_servers([server])
return await load_servers()
loaded = asyncio.run(run())
assert len(loaded) == 1
assert loaded[0].id == "p1"
assert loaded[0].password == "pw" # secrets persist on disk, 0600
server_files = [p for p in DATA_DIR.iterdir() if p.is_file()]
assert server_files, "expected persisted files in the data dir"
for path in server_files:
assert (os.stat(path).st_mode & 0o777) == 0o600, path
+45
View File
@@ -0,0 +1,45 @@
from backend.routing.rules import (
PRESETS,
preset_domain_tags,
preset_route_rules,
preset_rule_sets,
)
ALL = list(PRESETS.keys())
def test_presets_shape():
for key, preset in PRESETS.items():
assert preset["key"] == key
assert preset["name"] and preset["flag"] and preset["description"]
assert preset["rule_sets"], key
for rs in preset["rule_sets"]:
assert rs["type"] == "remote"
assert rs["format"] == "binary"
assert rs["url"].startswith("https://")
assert rs["download_detour"] == "direct"
def test_rule_set_tags_unique_across_presets():
tags = [rs["tag"] for p in PRESETS.values() for rs in p["rule_sets"]]
assert len(tags) == len(set(tags))
def test_route_rules_target_proxy():
rules = preset_route_rules(ALL)
assert len(rules) == len(ALL)
for rule in rules:
assert rule["outbound"] == "proxy"
assert rule["rule_set"]
def test_every_preset_has_a_domain_rule_set():
# The DNS layer resolves proxied domains through the tunnel; a preset
# whose tags all look IP-only would silently skip that protection.
for key in ALL:
assert preset_domain_tags([key]), key
def test_unknown_preset_ignored():
assert preset_rule_sets(["nope"]) == []
assert preset_route_rules(["nope"]) == []