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,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class _Base(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||
name: str
|
||||
protocol: str
|
||||
|
||||
|
||||
class SSHServer(_Base):
|
||||
protocol: Literal["ssh"] = "ssh"
|
||||
host: str
|
||||
port: int = 22
|
||||
user: str
|
||||
password: Optional[str] = None
|
||||
keyFile: Optional[str] = None
|
||||
localPort: int = 11080
|
||||
|
||||
|
||||
class VlessServer(_Base):
|
||||
protocol: Literal["vless"] = "vless"
|
||||
address: str
|
||||
port: int
|
||||
uuid: str
|
||||
transport: str = "tcp"
|
||||
tls: bool = False
|
||||
sni: Optional[str] = None
|
||||
security: Optional[Literal["tls", "reality", "none"]] = None
|
||||
flow: Optional[str] = None
|
||||
fp: Optional[str] = None
|
||||
pbk: Optional[str] = None
|
||||
sid: Optional[str] = None
|
||||
# for ws/grpc/http transports
|
||||
path: Optional[str] = None
|
||||
host: Optional[str] = None
|
||||
serviceName: Optional[str] = None
|
||||
|
||||
|
||||
class VmessServer(_Base):
|
||||
protocol: Literal["vmess"] = "vmess"
|
||||
address: str
|
||||
port: int
|
||||
uuid: str
|
||||
alterId: int = 0
|
||||
security: str = "auto"
|
||||
transport: str = "tcp"
|
||||
tls: bool = False
|
||||
sni: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
host: Optional[str] = None
|
||||
|
||||
|
||||
class ShadowsocksServer(_Base):
|
||||
protocol: Literal["shadowsocks"] = "shadowsocks"
|
||||
address: str
|
||||
port: int
|
||||
method: str
|
||||
password: str
|
||||
|
||||
|
||||
class Socks5Server(_Base):
|
||||
protocol: Literal["socks5"] = "socks5"
|
||||
host: str
|
||||
port: int
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
Server = Annotated[
|
||||
Union[SSHServer, VlessServer, VmessServer, ShadowsocksServer, Socks5Server],
|
||||
Field(discriminator="protocol"),
|
||||
]
|
||||
|
||||
|
||||
def parse_server(data: dict) -> Server:
|
||||
"""Parse a dict into one of the typed server models based on the protocol field."""
|
||||
protocol = (data.get("protocol") or "").lower()
|
||||
mapping = {
|
||||
"ssh": SSHServer,
|
||||
"vless": VlessServer,
|
||||
"vmess": VmessServer,
|
||||
"shadowsocks": ShadowsocksServer,
|
||||
"ss": ShadowsocksServer,
|
||||
"socks": Socks5Server,
|
||||
"socks5": Socks5Server,
|
||||
}
|
||||
cls = mapping.get(protocol)
|
||||
if cls is None:
|
||||
raise ValueError(f"Unsupported protocol: {protocol!r}")
|
||||
data = dict(data)
|
||||
if protocol == "ss":
|
||||
data["protocol"] = "shadowsocks"
|
||||
if protocol == "socks":
|
||||
data["protocol"] = "socks5"
|
||||
return cls.model_validate(data)
|
||||
|
||||
|
||||
def server_to_dict(server: BaseModel) -> dict:
|
||||
return server.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
# Connection secrets never leave the backend: list RPCs strip them, and
|
||||
# UpdateServer treats an empty value as "keep the stored one".
|
||||
SENSITIVE_FIELDS = ("password", "uuid")
|
||||
|
||||
|
||||
def server_to_public_dict(server: BaseModel) -> dict:
|
||||
data = server.model_dump(exclude_none=True)
|
||||
for key in SENSITIVE_FIELDS:
|
||||
data.pop(key, None)
|
||||
return data
|
||||
|
||||
|
||||
class RoutingRule(BaseModel):
|
||||
"""User-defined routing rule.
|
||||
|
||||
type: force-proxy → match → proxy outbound
|
||||
direct → match → direct outbound
|
||||
block → match → block outbound
|
||||
pattern: one of
|
||||
- "example.com" exact domain
|
||||
- "*.example.com" domain suffix (matches example.com + subdomains)
|
||||
- "10.0.0.0/8" CIDR (v4 or v6)
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||
name: Optional[str] = None
|
||||
enabled: bool = True
|
||||
type: Literal["force-proxy", "direct", "block"] = "force-proxy"
|
||||
pattern: str
|
||||
|
||||
def to_singbox_rule(self) -> Optional[dict]:
|
||||
if not self.enabled or not self.pattern:
|
||||
return None
|
||||
rule: dict = {}
|
||||
if self.type == "block":
|
||||
rule["action"] = "reject"
|
||||
else:
|
||||
rule["outbound"] = "proxy" if self.type == "force-proxy" else "direct"
|
||||
pat = self.pattern.strip()
|
||||
if "/" in pat and not pat.startswith("*"):
|
||||
rule["ip_cidr"] = [pat]
|
||||
elif pat.startswith("*."):
|
||||
rule["domain_suffix"] = [pat[2:]]
|
||||
elif pat.startswith("*"):
|
||||
rule["domain_keyword"] = [pat.lstrip("*")]
|
||||
else:
|
||||
rule["domain"] = [pat]
|
||||
return rule
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
activeServerId: Optional[str] = None
|
||||
mode: Literal["rules", "global"] = "rules"
|
||||
proxyMode: Literal["system", "tun"] = "system"
|
||||
autoStart: bool = False
|
||||
rulesPort: int = 11081
|
||||
globalPort: int = 11082
|
||||
transportPort: int = 11080
|
||||
refilterEnabled: bool = True
|
||||
healthCheckIntervalSec: int = 30
|
||||
killSwitchEnabled: bool = False
|
||||
clashApiPort: int = 11089
|
||||
showPingInBar: bool = True
|
||||
showTrafficInBar: bool = False
|
||||
activePresets: list[str] = Field(default_factory=lambda: ["ru"])
|
||||
|
||||
|
||||
class StatusInfo(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
running: bool = False
|
||||
activeServerId: Optional[str] = None
|
||||
mode: str = "rules"
|
||||
proxyMode: str = "system"
|
||||
transportPort: int = 11080
|
||||
muxPort: Optional[int] = None
|
||||
pids: dict[str, int] = Field(default_factory=dict)
|
||||
message: Optional[str] = None
|
||||
status: str = "ok" # ok | degraded | failed | error
|
||||
reason: Optional[str] = None
|
||||
Reference in New Issue
Block a user