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,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))
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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", ""
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]) == []
|
||||
Reference in New Issue
Block a user