diff --git a/GITEA_PUSH_HOWTO.md b/GITEA_PUSH_HOWTO.md index b88c773..c2b0b6b 100644 --- a/GITEA_PUSH_HOWTO.md +++ b/GITEA_PUSH_HOWTO.md @@ -72,4 +72,3 @@ sudo chown -R dev:dev ``` Then retry the rebase. - diff --git a/selective-vpn-gui/app_route_dialog.py b/selective-vpn-gui/app_route_dialog.py deleted file mode 100644 index 7289753..0000000 --- a/selective-vpn-gui/app_route_dialog.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/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() - diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index 037d81d..cca8c86 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -2,10 +2,14 @@ from __future__ import annotations import os +import shlex +import subprocess +import time from typing import Callable from PySide6 import QtCore, QtGui from PySide6.QtWidgets import ( + QButtonGroup, QCheckBox, QComboBox, QDialog, @@ -20,6 +24,7 @@ from PySide6.QtWidgets import ( QPlainTextEdit, QPushButton, QRadioButton, + QSpinBox, QTabWidget, QVBoxLayout, QWidget, @@ -154,6 +159,104 @@ RU: Восстанавливает маршруты/nft из последнег tab_basic_layout.addStretch(1) self.tabs.addTab(tab_basic, "Traffic basics") + # ----------------------------------------------------------------- + # Apps (runtime): systemd --user scope + backend appmarks + # ----------------------------------------------------------------- + + tab_apps = QWidget() + tab_apps_layout = QVBoxLayout(tab_apps) + + apps_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 Policy overrides for persistent policy." + ) + apps_hint.setWordWrap(True) + apps_hint.setStyleSheet("color: gray;") + tab_apps_layout.addWidget(apps_hint) + + run_group = QGroupBox("Run app in scope + apply mark") + run_layout = QVBoxLayout(run_group) + + row_cmd = QHBoxLayout() + row_cmd.addWidget(QLabel("Command")) + self.ed_app_cmd = QLineEdit() + self.ed_app_cmd.setPlaceholderText( + "e.g. firefox --private-window https://example.com" + ) + self.ed_app_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_app_cmd, stretch=1) + run_layout.addLayout(row_cmd) + + row_target = QHBoxLayout() + row_target.addWidget(QLabel("Route via")) + self.rad_app_vpn = QRadioButton("VPN") + self.rad_app_vpn.setToolTip( + "EN: Force this app traffic via VPN policy table (agvpn).\n" + "RU: Форсировать трафик приложения через VPN policy-table (agvpn)." + ) + self.rad_app_direct = QRadioButton("Direct") + self.rad_app_direct.setToolTip( + "EN: Force this app traffic to bypass VPN (lookup main), even in full tunnel.\n" + "RU: Форсировать трафик приложения мимо VPN (lookup main), даже в full tunnel." + ) + bg_app = QButtonGroup(self) + bg_app.addButton(self.rad_app_vpn) + bg_app.addButton(self.rad_app_direct) + self.rad_app_vpn.setChecked(True) + row_target.addWidget(self.rad_app_vpn) + row_target.addWidget(self.rad_app_direct) + row_target.addStretch(1) + run_layout.addLayout(row_target) + + row_ttl = QHBoxLayout() + row_ttl.addWidget(QLabel("TTL (hours)")) + self.spn_app_ttl = QSpinBox() + self.spn_app_ttl.setRange(1, 24 * 30) # up to ~30 days + self.spn_app_ttl.setValue(24) + self.spn_app_ttl.setToolTip( + "EN: How long the runtime mark stays active (backend nftset element timeout).\n" + "RU: Сколько живет runtime-метка (timeout элемента в nftset)." + ) + row_ttl.addWidget(self.spn_app_ttl) + row_ttl.addStretch(1) + run_layout.addLayout(row_ttl) + + row_btn = QHBoxLayout() + self.btn_app_run = QPushButton("Run + apply mark") + self.btn_app_run.clicked.connect(self.on_app_run) + row_btn.addWidget(self.btn_app_run) + self.btn_app_refresh = QPushButton("Refresh counts") + self.btn_app_refresh.clicked.connect(self.refresh_appmarks_counts) + row_btn.addWidget(self.btn_app_refresh) + self.btn_app_clear_vpn = QPushButton("Clear VPN marks") + self.btn_app_clear_vpn.clicked.connect(lambda: self.on_appmarks_clear("vpn")) + row_btn.addWidget(self.btn_app_clear_vpn) + self.btn_app_clear_direct = QPushButton("Clear Direct marks") + self.btn_app_clear_direct.clicked.connect( + lambda: self.on_appmarks_clear("direct") + ) + row_btn.addWidget(self.btn_app_clear_direct) + row_btn.addStretch(1) + run_layout.addLayout(row_btn) + + self.lbl_app_counts = QLabel("Marks: —") + self.lbl_app_counts.setStyleSheet("color: gray;") + run_layout.addWidget(self.lbl_app_counts) + + tab_apps_layout.addWidget(run_group) + + self.txt_app = QPlainTextEdit() + self.txt_app.setReadOnly(True) + tab_apps_layout.addWidget(self.txt_app, stretch=1) + + tab_apps_layout.addStretch(1) + self.tabs.addTab(tab_apps, "Apps (runtime)") + tab_adv = QWidget() tab_adv_layout = QVBoxLayout(tab_adv) @@ -251,6 +354,7 @@ RU: Применяет policy-rules и проверяет health. При оши root.addLayout(row_bottom) QtCore.QTimer.singleShot(0, self.refresh_state) + QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts) def _is_operation_error(self, message: str) -> bool: low = (message or "").strip().lower() @@ -622,6 +726,143 @@ RU: Применяет policy-rules и проверяет health. При оши self._safe(work, title="Apply overrides error") + # ----------------------------------------------------------------- + # Apps (runtime) tab + # ----------------------------------------------------------------- + + def _append_app_log(self, msg: str) -> None: + line = (msg or "").rstrip() + if not line: + return + try: + self.txt_app.appendPlainText(line) + except Exception: + pass + self._emit_log(line) + + def refresh_appmarks_counts(self) -> None: + try: + st = self.ctrl.traffic_appmarks_status() + self.lbl_app_counts.setText( + f"Marks: VPN={st.vpn_count}, Direct={st.direct_count}" + ) + except Exception as e: + self.lbl_app_counts.setText(f"Marks: error: {e}") + + def _run_systemd_scope(self, cmdline: str, *, unit: str) -> tuple[str, str]: + args = shlex.split(cmdline or "") + if not args: + raise ValueError("empty command") + + 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 "")).strip() + if p.returncode != 0: + raise RuntimeError(f"systemd-run failed: {p.returncode}\n{out}".strip()) + + p2 = subprocess.run( + ["systemctl", "--user", "show", "-p", "ControlGroup", "--value", unit], + capture_output=True, + text=True, + check=False, + ) + out2 = ((p2.stdout or "") + (p2.stderr or "")).strip() + cg = (p2.stdout or "").strip() + if p2.returncode != 0 or not cg: + raise RuntimeError(f"failed to query ControlGroup\n{out2}".strip()) + + return cg, out + + def on_app_run(self) -> None: + def work() -> None: + cmdline = (self.ed_app_cmd.text() or "").strip() + if not cmdline: + QMessageBox.warning(self, "Missing command", "Please enter a command to run.") + return + + target = "vpn" if self.rad_app_vpn.isChecked() else "direct" + ttl_sec = int(self.spn_app_ttl.value()) * 3600 + unit = f"svpn-{target}-{int(time.time())}.scope" + + self._append_app_log( + f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}" + ) + + cg, out = self._run_systemd_scope(cmdline, unit=unit) + if out: + self._append_app_log(f"[app] systemd-run:\n{out}") + self._append_app_log(f"[app] ControlGroup: {cg}") + + res = self.ctrl.traffic_appmarks_apply( + op="add", + target=target, + cgroup=cg, + timeout_sec=ttl_sec, + ) + if res.ok: + self._append_app_log( + f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s" + ) + self._set_action_status( + f"App mark added: target={target} cgroup_id={res.cgroup_id}", + ok=True, + ) + else: + self._append_app_log(f"[appmarks] ERROR: {res.message}") + self._set_action_status( + f"App mark failed: target={target} ({res.message})", + ok=False, + ) + QMessageBox.critical( + self, + "App mark error", + res.message or "unknown error", + ) + + self.refresh_appmarks_counts() + + self._safe(work, title="Run app error") + + def on_appmarks_clear(self, target: str) -> None: + tgt = (target or "").strip().lower() + if tgt not in ("vpn", "direct"): + return + + def work() -> None: + if QMessageBox.question( + self, + "Clear marks", + f"Clear ALL runtime app marks for target={tgt}?", + ) != QMessageBox.StandardButton.Yes: + return + + res = self.ctrl.traffic_appmarks_apply(op="clear", target=tgt) + if res.ok: + self._append_app_log(f"[appmarks] cleared: target={tgt}") + self._set_action_status(f"App marks cleared: target={tgt}", ok=True) + else: + self._append_app_log(f"[appmarks] clear error: {res.message}") + self._set_action_status( + f"App marks clear failed: target={tgt} ({res.message})", + ok=False, + ) + QMessageBox.critical( + self, "Clear marks error", res.message or "unknown error" + ) + + self.refresh_appmarks_counts() + + self._safe(work, title="Clear app marks error") + def on_rollback(self) -> None: def work() -> None: res = self.ctrl.routes_clear() @@ -800,6 +1041,33 @@ RU: Скрывает элементы, которые уже есть в Force V if filt is not None: filt.setText(text) + def _preset_filter_subnets(self, *, lan: bool, docker: bool, link: bool) -> None: + # EN: "Filter LAN/Docker" buttons should not rely on text search; they should + # EN: toggle the kind checkboxes and clear the text filter. + # RU: Кнопки "Filter LAN/Docker" не должны полагаться на текстовый поиск; + # RU: они должны переключать чекбоксы видов и очищать текстовый фильтр. + chk_lan = getattr(self, "chk_sub_show_lan", None) + chk_docker = getattr(self, "chk_sub_show_docker", None) + chk_link = getattr(self, "chk_sub_show_link", None) + if chk_lan is None or chk_docker is None or chk_link is None: + self._preset_set_filter("Subnets", "") + self._refilter_current() + return + + for chk, val in ( + (chk_lan, lan), + (chk_docker, docker), + (chk_link, link), + ): + try: + chk.blockSignals(True) + chk.setChecked(bool(val)) + finally: + chk.blockSignals(False) + + self._preset_set_filter("Subnets", "") + self._refilter_current() + def _update_item_render(self, it: QListWidgetItem, kind: str) -> None: value = str(it.data(QtCore.Qt.UserRole) or "").strip() base_label = str(it.data(QtCore.Qt.UserRole + 2) or it.text() or "") @@ -1021,13 +1289,13 @@ RU: Скрывает элементы, которые уже есть в Force V row2 = QHBoxLayout() btn_f_lan = QPushButton("Filter LAN") - btn_f_lan.clicked.connect(lambda: self._preset_set_filter("Subnets", "lan")) + btn_f_lan.clicked.connect(lambda: self._preset_filter_subnets(lan=True, docker=False, link=True)) row2.addWidget(btn_f_lan) btn_f_docker = QPushButton("Filter Docker") - btn_f_docker.clicked.connect(lambda: self._preset_set_filter("Subnets", "docker")) + btn_f_docker.clicked.connect(lambda: self._preset_filter_subnets(lan=False, docker=True, link=False)) row2.addWidget(btn_f_docker) btn_f_clear = QPushButton("Clear filter") - btn_f_clear.clicked.connect(lambda: self._preset_set_filter("Subnets", "")) + btn_f_clear.clicked.connect(lambda: self._preset_filter_subnets(lan=True, docker=True, link=True)) row2.addWidget(btn_f_clear) row2.addStretch(1) layout.addLayout(row2) @@ -1045,7 +1313,7 @@ RU: Скрывает элементы, которые уже есть в Force V self.chk_sub_show_docker.stateChanged.connect(lambda _s: self._refilter_current()) row3.addWidget(self.chk_sub_show_docker) - self.chk_sub_show_link = QCheckBox("Link") + self.chk_sub_show_link = QCheckBox("Link (scope link)") self.chk_sub_show_link.setChecked(True) self.chk_sub_show_link.setToolTip("EN: Show link-scope routes.\nRU: Показать маршруты scope link.") self.chk_sub_show_link.stateChanged.connect(lambda _s: self._refilter_current()) diff --git a/selective-vpn-gui/vpn_dashboard_qt.py b/selective-vpn-gui/vpn_dashboard_qt.py index 3bf2676..4fef265 100755 --- a/selective-vpn-gui/vpn_dashboard_qt.py +++ b/selective-vpn-gui/vpn_dashboard_qt.py @@ -36,7 +36,6 @@ 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") @@ -353,13 +352,6 @@ 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) @@ -1351,18 +1343,6 @@ 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()