From 70c5eea935749383675c6361b7b04a36b3fc0c9f Mon Sep 17 00:00:00 2001 From: beckline Date: Sun, 15 Feb 2026 16:31:19 +0300 Subject: [PATCH] ui+api: dedup per-app marks by app_key; auto-refresh runtime --- selective-vpn-api/app/traffic_appmarks.go | 50 +++++++- selective-vpn-api/app/types.go | 13 +- selective-vpn-gui/api_client.py | 9 ++ selective-vpn-gui/dashboard_controller.py | 6 + selective-vpn-gui/traffic_mode_dialog.py | 144 ++++++++++++++++++++-- 5 files changed, 206 insertions(+), 16 deletions(-) diff --git a/selective-vpn-api/app/traffic_appmarks.go b/selective-vpn-api/app/traffic_appmarks.go index d491cb7..d00e5a6 100644 --- a/selective-vpn-api/app/traffic_appmarks.go +++ b/selective-vpn-api/app/traffic_appmarks.go @@ -48,6 +48,9 @@ type appMarkItem struct { Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational CgroupRel string `json:"cgroup_rel"` Level int `json:"level"` + Unit string `json:"unit,omitempty"` + Command string `json:"command,omitempty"` + AppKey string `json:"app_key,omitempty"` AddedAt string `json:"added_at"` ExpiresAt string `json:"expires_at"` } @@ -74,6 +77,9 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { op := TrafficAppMarksOp(strings.ToLower(strings.TrimSpace(string(body.Op)))) target := strings.ToLower(strings.TrimSpace(body.Target)) cgroup := strings.TrimSpace(body.Cgroup) + unit := strings.TrimSpace(body.Unit) + command := strings.TrimSpace(body.Command) + appKey := strings.TrimSpace(body.AppKey) timeoutSec := body.TimeoutSec if op == "" { @@ -165,7 +171,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { } } - if err := appMarksAdd(target, inodeID, cgAbs, rel, level, ttl); err != nil { + if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), @@ -250,7 +256,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) { return vpnCount, directCount } -func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, ttlSec int) error { +func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int) error { target = strings.ToLower(strings.TrimSpace(target)) if target != "vpn" && target != "direct" { return fmt.Errorf("invalid target") @@ -271,6 +277,27 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, st := loadAppMarksState() changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) + unit = strings.TrimSpace(unit) + command = strings.TrimSpace(command) + appKey = normalizeAppKey(appKey, command) + + // EN: Avoid unbounded growth of marks for the same app. + // RU: Не даём бесконечно плодить метки на одно и то же приложение. + if appKey != "" { + kept := st.Items[:0] + for _, it := range st.Items { + if strings.ToLower(strings.TrimSpace(it.Target)) == target && + strings.TrimSpace(it.AppKey) == appKey && + it.ID != id { + _ = nftDeleteAppMarkRule(target, it.ID) + changed = true + continue + } + kept = append(kept, it) + } + st.Items = kept + } + // Replace any existing rule/state for this (target,id). _ = nftDeleteAppMarkRule(target, id) if err := nftInsertAppMarkRule(target, rel, level, id); err != nil { @@ -284,6 +311,9 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, Cgroup: cgAbs, CgroupRel: rel, Level: level, + Unit: unit, + Command: command, + AppKey: appKey, AddedAt: now.Format(time.RFC3339), ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339), } @@ -613,6 +643,22 @@ func saveAppMarksState(st appMarksState) error { return os.Rename(tmp, trafficAppMarksPath) } +func normalizeAppKey(appKey string, command string) string { + key := strings.TrimSpace(appKey) + if key != "" { + return key + } + cmd := strings.TrimSpace(command) + if cmd == "" { + return "" + } + fields := strings.Fields(cmd) + if len(fields) > 0 { + return strings.TrimSpace(fields[0]) + } + return cmd +} + func isAllDigits(s string) bool { s = strings.TrimSpace(s) if s == "" { diff --git a/selective-vpn-api/app/types.go b/selective-vpn-api/app/types.go index 6229ac6..d7fce74 100644 --- a/selective-vpn-api/app/types.go +++ b/selective-vpn-api/app/types.go @@ -200,10 +200,15 @@ const ( // EN: Runtime app marking request. Used by per-app launcher wrappers. // RU: Runtime app marking запрос. Используется wrapper-лаунчером per-app. type TrafficAppMarksRequest struct { - Op TrafficAppMarksOp `json:"op"` - Target string `json:"target"` // vpn|direct - Cgroup string `json:"cgroup,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` // only for add + Op TrafficAppMarksOp `json:"op"` + Target string `json:"target"` // vpn|direct + Cgroup string `json:"cgroup,omitempty"` + // EN: Optional metadata to deduplicate marks per-app across restarts / re-runs. + // RU: Опциональные метаданные, чтобы не плодить метки на одно и то же приложение. + Unit string `json:"unit,omitempty"` + Command string `json:"command,omitempty"` + AppKey string `json:"app_key,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` // only for add } type TrafficAppMarksResponse struct { diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index c8a861f..a32a4d9 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -825,6 +825,9 @@ class ApiClient: op: str, target: str, cgroup: str = "", + unit: str = "", + command: str = "", + app_key: str = "", timeout_sec: int = 0, ) -> TrafficAppMarksResult: payload: Dict[str, Any] = { @@ -833,6 +836,12 @@ class ApiClient: } if cgroup: payload["cgroup"] = str(cgroup).strip() + if unit: + payload["unit"] = str(unit).strip() + if command: + payload["command"] = str(command).strip() + if app_key: + payload["app_key"] = str(app_key).strip() if int(timeout_sec or 0) > 0: payload["timeout_sec"] = int(timeout_sec) diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index 91d34a3..220d4e1 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -716,12 +716,18 @@ class DashboardController: op: str, target: str, cgroup: str = "", + unit: str = "", + command: str = "", + app_key: str = "", timeout_sec: int = 0, ) -> TrafficAppMarksResult: return self.client.traffic_appmarks_apply( op=op, target=target, cgroup=cgroup, + unit=unit, + command=command, + app_key=app_key, timeout_sec=timeout_sec, ) diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index 40b7f79..afcb337 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -82,6 +82,8 @@ class TrafficModeDialog(QDialog): self._settings = QtCore.QSettings("AdGuardVPN", "SelectiveVPNDashboardQt") self._last_app_unit: str = str(self._settings.value("traffic_app_last_unit", "") or "") self._last_app_target: str = str(self._settings.value("traffic_app_last_target", "") or "") + self._last_app_key: str = str(self._settings.value("traffic_app_last_key", "") or "") + self._last_app_cmdline: str = str(self._settings.value("traffic_app_last_cmdline", "") or "") try: self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0) except Exception: @@ -468,6 +470,23 @@ RU: Применяет policy-rules и проверяет health. При оши QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts) QtCore.QTimer.singleShot(0, self.refresh_running_scopes) + # EN: Auto-refresh runtime marks/units while dialog is open. + # RU: Авто-обновление runtime меток/юнитов пока окно открыто. + self._runtime_auto_timer = QtCore.QTimer(self) + self._runtime_auto_timer.setInterval(5000) + self._runtime_auto_timer.timeout.connect(self._auto_refresh_runtime) + self._runtime_auto_timer.start() + + def _auto_refresh_runtime(self) -> None: + # Keep this quiet: no modal popups. + self.refresh_appmarks_counts() + try: + # Only refresh units list when Apps(runtime) tab is visible. + if int(self.tabs.currentIndex() or 0) == 1: + self.refresh_running_scopes(quiet=True) + except Exception: + pass + def _is_operation_error(self, message: str) -> bool: low = (message or "").strip().lower() return ("rolled back" in low) or ("apply failed" in low) or ("verification failed" in low) @@ -864,6 +883,7 @@ RU: Применяет policy-rules и проверяет health. При оши def _refresh_last_scope_ui(self) -> None: unit = (self._last_app_unit or "").strip() target = (self._last_app_target or "").strip().lower() + app_key = (self._last_app_key or "").strip() cg_id = int(self._last_app_cgroup_id or 0) parts = [] @@ -871,6 +891,8 @@ RU: Применяет policy-rules и проверяет health. При оши parts.append(f"unit={unit}") if target in ("vpn", "direct"): parts.append(f"target={target}") + if app_key: + parts.append(f"app={app_key}") if cg_id > 0: parts.append(f"cgroup_id={cg_id}") @@ -882,9 +904,19 @@ RU: Применяет policy-rules и проверяет health. При оши self.btn_app_stop_last.setEnabled(bool(unit)) self.btn_app_unmark_last.setEnabled(bool(unit) and target in ("vpn", "direct") and cg_id > 0) - def _set_last_scope(self, *, unit: str = "", target: str = "", cgroup_id: int = 0) -> None: + def _set_last_scope( + self, + *, + unit: str = "", + target: str = "", + app_key: str = "", + cmdline: str = "", + cgroup_id: int = 0, + ) -> None: self._last_app_unit = str(unit or "").strip() self._last_app_target = str(target or "").strip().lower() + self._last_app_key = str(app_key or "").strip() + self._last_app_cmdline = str(cmdline or "").strip() try: self._last_app_cgroup_id = int(cgroup_id or 0) except Exception: @@ -892,6 +924,8 @@ RU: Применяет policy-rules и проверяет health. При оши self._settings.setValue("traffic_app_last_unit", self._last_app_unit) self._settings.setValue("traffic_app_last_target", self._last_app_target) + self._settings.setValue("traffic_app_last_key", self._last_app_key) + self._settings.setValue("traffic_app_last_cmdline", self._last_app_cmdline) self._settings.setValue("traffic_app_last_cgroup_id", int(self._last_app_cgroup_id or 0)) self._refresh_last_scope_ui() @@ -1058,24 +1092,86 @@ RU: Применяет policy-rules и проверяет health. При оши QMessageBox.warning(self, "Missing command", "Please enter a command to run.") return + app_key = "" + try: + args = shlex.split(cmdline or "") + if args: + app_key = str(args[0] or "").strip() + except Exception: + app_key = "" + if not app_key: + # Fallback: best-effort first token. + app_key = (cmdline.split() or [""])[0].strip() + target = "vpn" if self.rad_app_vpn.isChecked() else "direct" ttl_sec = int(self.spn_app_ttl.value()) * 3600 + + # EN: If the app is already running inside the last svpn unit, don't spawn a new instance. + # RU: Если приложение уже запущено в последнем svpn unit, не запускаем второй экземпляр. + last_unit = (self._last_app_unit or "").strip() + last_target = (self._last_app_target or "").strip().lower() + last_key = (self._last_app_key or "").strip() + if last_unit and last_target == target and last_key and last_key == app_key: + code, out = self._systemctl_user(["is-active", last_unit]) + if code == 0 and (out or "").strip().lower() == "active": + cg = self._query_control_group_for_unit(last_unit) + self._append_app_log( + f"[app] already running: app={app_key} target={target} unit={last_unit} (refreshing mark)" + ) + res = self.ctrl.traffic_appmarks_apply( + op="add", + target=target, + cgroup=cg, + unit=last_unit, + command=cmdline, + app_key=app_key, + 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 refreshed: target={target} cgroup_id={res.cgroup_id}", + ok=True, + ) + self._set_last_scope( + unit=last_unit, + target=target, + app_key=app_key, + cmdline=cmdline, + cgroup_id=int(res.cgroup_id or 0), + ) + else: + self._append_app_log(f"[appmarks] ERROR: {res.message}") + self._set_action_status( + f"App mark refresh failed: target={target} ({res.message})", + ok=False, + ) + QMessageBox.critical(self, "App mark error", res.message or "unknown error") + self.refresh_appmarks_counts() + self.refresh_running_scopes() + return + unit = f"svpn-{target}-{int(time.time())}.service" self._append_app_log( - f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}" + f"[app] launching: app={app_key or '-'} target={target} ttl={ttl_sec}s unit={unit}" ) cg, out = self._run_systemd_unit(cmdline, unit=unit) if out: self._append_app_log(f"[app] systemd-run:\n{out}") self._append_app_log(f"[app] ControlGroup: {cg}") - self._set_last_scope(unit=unit, target=target, cgroup_id=0) + self._set_last_scope(unit=unit, target=target, app_key=app_key, cmdline=cmdline, cgroup_id=0) res = self.ctrl.traffic_appmarks_apply( op="add", target=target, cgroup=cg, + unit=unit, + command=cmdline, + app_key=app_key, timeout_sec=ttl_sec, ) if res.ok: @@ -1086,20 +1182,40 @@ RU: Применяет policy-rules и проверяет health. При оши f"App mark added: target={target} cgroup_id={res.cgroup_id}", ok=True, ) - self._set_last_scope(unit=unit, target=target, cgroup_id=int(res.cgroup_id or 0)) + self._set_last_scope( + unit=unit, + target=target, + app_key=app_key, + cmdline=cmdline, + cgroup_id=int(res.cgroup_id or 0), + ) 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", - ) + low = (res.message or "").lower() + if "cgroupv2 path fails" in low or "no such file or directory" in low: + QMessageBox.critical( + self, + "App mark error", + (res.message or "unknown error") + + "\n\n" + + "EN: This usually means the app didn't stay inside the new systemd unit " + + "(often because it was already running). Close the app completely and run again from here.\n" + + "RU: Обычно это значит, что приложение не осталось в новом systemd unit " + + "(часто потому что оно уже было запущено). Полностью закрой приложение и запусти снова отсюда.", + ) + else: + QMessageBox.critical( + self, + "App mark error", + res.message or "unknown error", + ) self.refresh_appmarks_counts() + self.refresh_running_scopes() self._safe(work, title="Run app error") @@ -1292,7 +1408,7 @@ RU: Применяет policy-rules и проверяет health. При оши except Exception: return 0 - def refresh_running_scopes(self) -> None: + def refresh_running_scopes(self, *, quiet: bool = False) -> None: def work() -> None: units = self._list_running_svpn_units() self.lst_scopes.clear() @@ -1347,6 +1463,14 @@ RU: Применяет policy-rules и проверяет health. При оши self.btn_scopes_stop_selected.setEnabled(has_any) self.btn_scopes_cleanup.setEnabled(has_any) + if quiet: + try: + work() + except Exception: + # Quiet mode: avoid modal popups on background refresh. + pass + return + self._safe(work, title="Units refresh error") def _copy_scope_unit(self, it: QListWidgetItem) -> None: