traffic: add per-app runtime app routing via cgroup marks
This commit is contained in:
@@ -120,6 +120,24 @@ class TrafficInterfaces:
|
||||
iface_reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficAppMarksStatus:
|
||||
vpn_count: int
|
||||
direct_count: int
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficAppMarksResult:
|
||||
ok: bool
|
||||
message: str
|
||||
op: str = ""
|
||||
target: str = ""
|
||||
cgroup: str = ""
|
||||
cgroup_id: int = 0
|
||||
timeout_sec: int = 0
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficCandidateSubnet:
|
||||
@@ -790,6 +808,49 @@ class ApiClient:
|
||||
uids=uids,
|
||||
)
|
||||
|
||||
def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(self._request("GET", "/api/v1/traffic/appmarks")) or {},
|
||||
)
|
||||
return TrafficAppMarksStatus(
|
||||
vpn_count=int(data.get("vpn_count", 0) or 0),
|
||||
direct_count=int(data.get("direct_count", 0) or 0),
|
||||
message=str(data.get("message") or ""),
|
||||
)
|
||||
|
||||
def traffic_appmarks_apply(
|
||||
self,
|
||||
*,
|
||||
op: str,
|
||||
target: str,
|
||||
cgroup: str = "",
|
||||
timeout_sec: int = 0,
|
||||
) -> TrafficAppMarksResult:
|
||||
payload: Dict[str, Any] = {
|
||||
"op": str(op or "").strip().lower(),
|
||||
"target": str(target or "").strip().lower(),
|
||||
}
|
||||
if cgroup:
|
||||
payload["cgroup"] = str(cgroup).strip()
|
||||
if int(timeout_sec or 0) > 0:
|
||||
payload["timeout_sec"] = int(timeout_sec)
|
||||
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(self._request("POST", "/api/v1/traffic/appmarks", json_body=payload))
|
||||
or {},
|
||||
)
|
||||
return TrafficAppMarksResult(
|
||||
ok=bool(data.get("ok", False)),
|
||||
message=str(data.get("message") or ""),
|
||||
op=str(data.get("op") or payload["op"]),
|
||||
target=str(data.get("target") or payload["target"]),
|
||||
cgroup=str(data.get("cgroup") or payload.get("cgroup") or ""),
|
||||
cgroup_id=int(data.get("cgroup_id", 0) or 0),
|
||||
timeout_sec=int(data.get("timeout_sec", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
# DNS / SmartDNS
|
||||
def dns_upstreams_get(self) -> DnsUpstreams:
|
||||
|
||||
261
selective-vpn-gui/app_route_dialog.py
Normal file
261
selective-vpn-gui/app_route_dialog.py
Normal file
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from dashboard_controller import DashboardController
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunScopeResult:
|
||||
ok: bool
|
||||
unit: str = ""
|
||||
cgroup: str = ""
|
||||
message: str = ""
|
||||
stdout: str = ""
|
||||
|
||||
|
||||
class AppRouteDialog(QDialog):
|
||||
"""
|
||||
EN: Launch an app inside a systemd --user scope and register its cgroup id in backend nftsets.
|
||||
RU: Запускает приложение в systemd --user scope и регистрирует его cgroup id в nftset'ах backend-а.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: DashboardController,
|
||||
*,
|
||||
log_cb: Callable[[str], None] | None = None,
|
||||
parent=None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.ctrl = controller
|
||||
self.log_cb = log_cb
|
||||
|
||||
self.setWindowTitle("Run app via VPN / Direct (runtime)")
|
||||
self.resize(720, 520)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
hint = QLabel(
|
||||
"Runtime per-app routing (Wayland-friendly):\n"
|
||||
"- Launch uses systemd-run --user --scope.\n"
|
||||
"- Backend adds the scope cgroup into nftset -> fwmark rules.\n"
|
||||
"- Marks are temporary (TTL). Use Traffic overrides for persistent policy."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("color: gray;")
|
||||
root.addWidget(hint)
|
||||
|
||||
grp = QGroupBox("Run")
|
||||
gl = QVBoxLayout(grp)
|
||||
|
||||
row_cmd = QHBoxLayout()
|
||||
row_cmd.addWidget(QLabel("Command"))
|
||||
self.ed_cmd = QLineEdit()
|
||||
self.ed_cmd.setPlaceholderText("e.g. firefox --private-window https://example.com")
|
||||
self.ed_cmd.setToolTip(
|
||||
"EN: Command line to run. This runs as current user in a systemd --user scope.\n"
|
||||
"RU: Команда запуска. Запускается от текущего пользователя в systemd --user scope."
|
||||
)
|
||||
row_cmd.addWidget(self.ed_cmd, stretch=1)
|
||||
gl.addLayout(row_cmd)
|
||||
|
||||
row_target = QHBoxLayout()
|
||||
row_target.addWidget(QLabel("Route via"))
|
||||
|
||||
self.rad_vpn = QRadioButton("VPN")
|
||||
self.rad_vpn.setToolTip(
|
||||
"EN: Force this app traffic via VPN policy table (agvpn).\n"
|
||||
"RU: Форсировать трафик приложения через VPN policy-table (agvpn)."
|
||||
)
|
||||
self.rad_direct = QRadioButton("Direct")
|
||||
self.rad_direct.setToolTip(
|
||||
"EN: Force this app traffic to bypass VPN (lookup main), even in full tunnel.\n"
|
||||
"RU: Форсировать трафик приложения мимо VPN (lookup main), даже в full tunnel."
|
||||
)
|
||||
|
||||
bg = QButtonGroup(self)
|
||||
bg.addButton(self.rad_vpn)
|
||||
bg.addButton(self.rad_direct)
|
||||
self.rad_vpn.setChecked(True)
|
||||
row_target.addWidget(self.rad_vpn)
|
||||
row_target.addWidget(self.rad_direct)
|
||||
row_target.addStretch(1)
|
||||
|
||||
row_ttl = QHBoxLayout()
|
||||
row_ttl.addWidget(QLabel("TTL (hours)"))
|
||||
self.spn_ttl = QSpinBox()
|
||||
self.spn_ttl.setRange(1, 24 * 30) # up to ~30 days
|
||||
self.spn_ttl.setValue(24)
|
||||
self.spn_ttl.setToolTip(
|
||||
"EN: How long the runtime mark stays active (backend nftset element timeout).\n"
|
||||
"RU: Сколько живет runtime-метка (timeout элемента в nftset)."
|
||||
)
|
||||
row_ttl.addWidget(self.spn_ttl)
|
||||
row_ttl.addStretch(1)
|
||||
|
||||
gl.addLayout(row_target)
|
||||
gl.addLayout(row_ttl)
|
||||
|
||||
row_btn = QHBoxLayout()
|
||||
self.btn_run = QPushButton("Run in scope + apply mark")
|
||||
self.btn_run.clicked.connect(self.on_run_clicked)
|
||||
row_btn.addWidget(self.btn_run)
|
||||
self.btn_refresh = QPushButton("Refresh counts")
|
||||
self.btn_refresh.clicked.connect(self.on_refresh_counts)
|
||||
row_btn.addWidget(self.btn_refresh)
|
||||
row_btn.addStretch(1)
|
||||
gl.addLayout(row_btn)
|
||||
|
||||
self.lbl_counts = QLabel("Marks: —")
|
||||
self.lbl_counts.setStyleSheet("color: gray;")
|
||||
gl.addWidget(self.lbl_counts)
|
||||
|
||||
root.addWidget(grp)
|
||||
|
||||
self.txt = QPlainTextEdit()
|
||||
self.txt.setReadOnly(True)
|
||||
root.addWidget(self.txt, stretch=1)
|
||||
|
||||
row_bottom = QHBoxLayout()
|
||||
row_bottom.addStretch(1)
|
||||
btn_close = QPushButton("Close")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
row_bottom.addWidget(btn_close)
|
||||
root.addLayout(row_bottom)
|
||||
|
||||
QtCore.QTimer.singleShot(0, self.on_refresh_counts)
|
||||
|
||||
def _emit_log(self, msg: str) -> None:
|
||||
text = (msg or "").strip()
|
||||
if not text:
|
||||
return
|
||||
if self.log_cb:
|
||||
self.log_cb(text)
|
||||
return
|
||||
try:
|
||||
self.ctrl.log_gui(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _append(self, msg: str) -> None:
|
||||
text = (msg or "").rstrip()
|
||||
if not text:
|
||||
return
|
||||
self.txt.appendPlainText(text)
|
||||
self._emit_log(text)
|
||||
|
||||
def on_refresh_counts(self) -> None:
|
||||
try:
|
||||
st = self.ctrl.traffic_appmarks_status()
|
||||
self.lbl_counts.setText(f"Marks: VPN={st.vpn_count}, Direct={st.direct_count}")
|
||||
except Exception as e:
|
||||
self.lbl_counts.setText(f"Marks: error: {e}")
|
||||
|
||||
def _run_scope(self, cmdline: str, *, unit: str) -> RunScopeResult:
|
||||
args = shlex.split(cmdline)
|
||||
if not args:
|
||||
return RunScopeResult(ok=False, message="empty command")
|
||||
|
||||
# Launch the scope.
|
||||
run_cmd = [
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--scope",
|
||||
"--unit",
|
||||
unit,
|
||||
"--collect",
|
||||
"--same-dir",
|
||||
] + args
|
||||
|
||||
p = subprocess.run(
|
||||
run_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
out = (p.stdout or "") + (p.stderr or "")
|
||||
if p.returncode != 0:
|
||||
return RunScopeResult(ok=False, unit=unit, message=f"systemd-run failed: {p.returncode}", stdout=out.strip())
|
||||
|
||||
# Get cgroup path for this unit.
|
||||
p2 = subprocess.run(
|
||||
["systemctl", "--user", "show", "-p", "ControlGroup", "--value", unit],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
cg = (p2.stdout or "").strip()
|
||||
out2 = (p2.stdout or "") + (p2.stderr or "")
|
||||
if p2.returncode != 0 or not cg:
|
||||
return RunScopeResult(ok=False, unit=unit, message="failed to query ControlGroup", stdout=(out + "\n" + out2).strip())
|
||||
|
||||
return RunScopeResult(ok=True, unit=unit, cgroup=cg, message="ok", stdout=out.strip())
|
||||
|
||||
def on_run_clicked(self) -> None:
|
||||
cmdline = self.ed_cmd.text().strip()
|
||||
if not cmdline:
|
||||
QMessageBox.warning(self, "Missing command", "Please enter a command to run.")
|
||||
return
|
||||
|
||||
target = "vpn" if self.rad_vpn.isChecked() else "direct"
|
||||
ttl_sec = int(self.spn_ttl.value()) * 3600
|
||||
unit = f"svpn-{target}-{int(time.time())}.scope"
|
||||
|
||||
self._append(f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}")
|
||||
|
||||
try:
|
||||
rr = self._run_scope(cmdline, unit=unit)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Run failed", str(e))
|
||||
return
|
||||
|
||||
if not rr.ok:
|
||||
self._append(f"[app] ERROR: {rr.message}\n{rr.stdout}".rstrip())
|
||||
QMessageBox.critical(self, "Run failed", rr.message)
|
||||
return
|
||||
|
||||
self._append(f"[app] scope started: unit={rr.unit}")
|
||||
self._append(f"[app] ControlGroup: {rr.cgroup}")
|
||||
|
||||
try:
|
||||
res = self.ctrl.traffic_appmarks_apply(
|
||||
op="add",
|
||||
target=target,
|
||||
cgroup=rr.cgroup,
|
||||
timeout_sec=ttl_sec,
|
||||
)
|
||||
except Exception as e:
|
||||
self._append(f"[appmarks] ERROR calling API: {e}")
|
||||
QMessageBox.critical(self, "API error", str(e))
|
||||
return
|
||||
|
||||
if res.ok:
|
||||
self._append(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s")
|
||||
else:
|
||||
self._append(f"[appmarks] ERROR: {res.message}")
|
||||
QMessageBox.critical(self, "App mark error", res.message or "unknown error")
|
||||
|
||||
self.on_refresh_counts()
|
||||
|
||||
@@ -32,6 +32,8 @@ from api_client import (
|
||||
LoginState,
|
||||
Status,
|
||||
TrafficCandidates,
|
||||
TrafficAppMarksResult,
|
||||
TrafficAppMarksStatus,
|
||||
TrafficInterfaces,
|
||||
TrafficModeStatus,
|
||||
TraceDump,
|
||||
@@ -705,6 +707,23 @@ class DashboardController:
|
||||
def traffic_candidates(self) -> TrafficCandidates:
|
||||
return self.client.traffic_candidates_get()
|
||||
|
||||
def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
|
||||
return self.client.traffic_appmarks_status()
|
||||
|
||||
def traffic_appmarks_apply(
|
||||
self,
|
||||
*,
|
||||
op: str,
|
||||
target: str,
|
||||
cgroup: str = "",
|
||||
timeout_sec: int = 0,
|
||||
) -> TrafficAppMarksResult:
|
||||
return self.client.traffic_appmarks_apply(
|
||||
op=op,
|
||||
target=target,
|
||||
cgroup=cgroup,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView:
|
||||
"""
|
||||
|
||||
@@ -36,6 +36,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from api_client import ApiClient, DnsUpstreams
|
||||
from dashboard_controller import DashboardController, TraceMode
|
||||
from app_route_dialog import AppRouteDialog
|
||||
from traffic_mode_dialog import TrafficModeDialog
|
||||
|
||||
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
|
||||
@@ -352,6 +353,13 @@ class MainWindow(QMainWindow):
|
||||
self.btn_traffic_settings = QPushButton("Open traffic settings")
|
||||
self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings)
|
||||
relay_row.addWidget(self.btn_traffic_settings)
|
||||
self.btn_app_route = QPushButton("Run app via VPN/Direct")
|
||||
self.btn_app_route.setToolTip(
|
||||
"EN: Launch an app in a systemd --user scope and apply a temporary per-app routing mark (Wayland-friendly).\n"
|
||||
"RU: Запуск приложения в systemd --user scope + временная per-app метка маршрутизации."
|
||||
)
|
||||
self.btn_app_route.clicked.connect(self.on_open_app_route)
|
||||
relay_row.addWidget(self.btn_app_route)
|
||||
self.btn_traffic_test = QPushButton("Test mode")
|
||||
self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode)
|
||||
relay_row.addWidget(self.btn_traffic_test)
|
||||
@@ -1343,6 +1351,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
refresh_all_traffic()
|
||||
self._safe(work, title="Traffic mode dialog error")
|
||||
|
||||
def on_open_app_route(self) -> None:
|
||||
def work():
|
||||
dlg = AppRouteDialog(
|
||||
self.ctrl,
|
||||
log_cb=self._append_routes_log,
|
||||
parent=self,
|
||||
)
|
||||
dlg.exec()
|
||||
self.refresh_routes_tab()
|
||||
self.refresh_status_tab()
|
||||
self._safe(work, title="App route dialog error")
|
||||
|
||||
def on_test_traffic_mode(self) -> None:
|
||||
def work():
|
||||
view = self.ctrl.traffic_mode_test()
|
||||
|
||||
Reference in New Issue
Block a user