Files
community-plugins/ruh-vpn/backend/service/kill_switch.py
T
0733efd186 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>
2026-08-09 21:03:02 -04:00

152 lines
5.4 KiB
Python

"""nftables-backed kill switch.
Generates a self-contained inet table that drops all traffic except:
- loopback
- established / related connections
- the noctalia-tun0 device (when present)
- the proxy mux/transport ports on 127.0.0.1 (already covered by loopback)
- explicit allowances for the active VPN server's host:port (so sing-box
can dial out to it after the rules are installed)
The table is named "noctalia_killswitch" so removal/replacement is cheap and
does not touch anyone else's nftables config.
"""
from __future__ import annotations
import asyncio
import ipaddress
import shutil
import subprocess
from typing import Optional
TABLE_NAME = "noctalia_killswitch"
NFT_BIN = "/usr/sbin/nft"
def _nft_path() -> str:
return shutil.which("nft") or NFT_BIN
def build_ruleset(
server_ips: Optional[list[str]],
server_port: Optional[int],
tun_iface: str = "noctalia-tun0",
extra_allow_tcp: Optional[list[int]] = None,
) -> str:
"""Build the nft ruleset text.
server_ips must be literal IP addresses (the caller resolves domain names
beforehand). Every value is re-parsed through the ipaddress module and
re-emitted in canonical form; anything that does not parse is dropped, so
an untrusted server entry can never inject nft syntax into the ruleset,
which runs with root privileges.
"""
port = int(server_port) if server_port else None
tcp_ports = [int(p) for p in (extra_allow_tcp or [])]
server_lines = ""
for raw in server_ips or []:
try:
ip = ipaddress.ip_address(str(raw).strip())
except ValueError:
continue
keyword = "ip6" if ip.version == 6 else "ip"
if port:
server_lines += f" {keyword} daddr {ip} tcp dport {port} accept\n"
else:
server_lines += f" {keyword} daddr {ip} accept\n"
tcp_port_line = ""
if tcp_ports:
ports = "{ " + ", ".join(str(p) for p in tcp_ports) + " }"
tcp_port_line = f" tcp dport {ports} accept\n"
return (
f"table inet {TABLE_NAME} {{\n"
f" chain output {{\n"
f" type filter hook output priority filter; policy drop;\n"
f" oif \"lo\" accept\n"
f" ct state established,related accept\n"
f" oifname \"{tun_iface}\" accept\n"
f" udp dport 53 accept\n"
f" ip daddr 192.168.0.0/16 accept\n"
f" ip daddr 10.0.0.0/8 accept\n"
f" ip daddr 172.16.0.0/12 accept\n"
f"{server_lines}{tcp_port_line}"
f" }}\n"
f" chain input {{\n"
f" type filter hook input priority filter; policy drop;\n"
f" iif \"lo\" accept\n"
f" ct state established,related accept\n"
f" iifname \"{tun_iface}\" accept\n"
f" }}\n"
f"}}\n"
)
async def _run_nft(args: list[str], input_text: Optional[str] = None) -> tuple[int, str]:
nft = _nft_path()
proc = await asyncio.create_subprocess_exec(
nft,
*args,
stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
out, _ = await proc.communicate(input_text.encode() if input_text else None)
return proc.returncode or 0, out.decode("utf-8", errors="replace")
async def _run_nft_via_pkexec(args: list[str], input_text: Optional[str] = None) -> tuple[int, str]:
if shutil.which("pkexec") is None:
return 1, "pkexec not available"
nft = _nft_path()
cmd = ["pkexec", nft, *args]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
out, _ = await proc.communicate(input_text.encode() if input_text else None)
return proc.returncode or 0, out.decode("utf-8", errors="replace")
async def apply(ruleset: str) -> tuple[bool, str]:
"""Install (or replace) the kill switch ruleset. Returns (ok, message)."""
# remove any prior version atomically before re-adding (idempotent)
purge_cmd = f"delete table inet {TABLE_NAME}\n" + ruleset
rc, out = await _run_nft(["-f", "-"], input_text=purge_cmd)
if rc == 0:
return True, "applied"
# try just the add (no prior table)
rc2, out2 = await _run_nft(["-f", "-"], input_text=ruleset)
if rc2 == 0:
return True, "applied"
# fall back to pkexec
rc3, out3 = await _run_nft_via_pkexec(["-f", "-"], input_text=purge_cmd)
if rc3 == 0:
return True, "applied via pkexec"
rc4, out4 = await _run_nft_via_pkexec(["-f", "-"], input_text=ruleset)
if rc4 == 0:
return True, "applied via pkexec"
return False, f"nft failed: {out2 or out}; pkexec: {out4 or out3}"
async def remove() -> tuple[bool, str]:
rc, out = await _run_nft(["delete", "table", "inet", TABLE_NAME])
if rc == 0:
return True, "removed"
rc2, out2 = await _run_nft_via_pkexec(["delete", "table", "inet", TABLE_NAME])
if rc2 == 0:
return True, "removed via pkexec"
# if the table doesn't exist, treat as success
if "No such file or directory" in (out + out2) or "does not exist" in (out + out2):
return True, "no table to remove"
return False, f"nft failed: {out}; pkexec: {out2}"
async def is_active() -> bool:
rc, out = await _run_nft(["list", "table", "inet", TABLE_NAME])
return rc == 0