From 2b32427e5b58f11f0b12fcb9fca18ba6cad198f6 Mon Sep 17 00:00:00 2001 From: beckline Date: Sun, 15 Feb 2026 22:56:00 +0300 Subject: [PATCH] ui: allow appmark by PID (no launch) --- selective-vpn-gui/traffic_mode_dialog.py | 104 ++++++++++++++++++++++- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index 75d97b2..043bd5c 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -393,6 +393,27 @@ RU: Восстанавливает маршруты/nft из последнег row_ttl.addStretch(1) run_layout.addLayout(row_ttl) + pid_group = QGroupBox("Mark existing PID (no launch)") + pid_layout = QHBoxLayout(pid_group) + pid_layout.addWidget(QLabel("PID")) + self.ed_app_pid = QLineEdit() + self.ed_app_pid.setPlaceholderText("e.g. 12345") + self.ed_app_pid.setToolTip( + "EN: Apply a runtime mark to an already running process.\n" + "EN: Reads /proc//cgroup to get a cgroupv2 path.\n" + "RU: Применить runtime-метку к уже запущенному процессу.\n" + "RU: Читает /proc//cgroup чтобы получить cgroupv2 path." + ) + pid_layout.addWidget(self.ed_app_pid, stretch=1) + self.btn_app_mark_pid = QPushButton("Apply mark") + self.btn_app_mark_pid.setToolTip( + "EN: Apply routing mark to the PID (does not launch/stop the app).\n" + "RU: Применить метку маршрутизации к PID (не запускает/не останавливает приложение)." + ) + self.btn_app_mark_pid.clicked.connect(self.on_app_mark_pid) + pid_layout.addWidget(self.btn_app_mark_pid) + run_layout.addWidget(pid_group) + row_btn = QHBoxLayout() self.btn_app_run = QPushButton("Run + apply mark") self.btn_app_run.clicked.connect(self.on_app_run) @@ -1650,7 +1671,7 @@ RU: Применяет policy-rules и проверяет health. При оши self.lbl_app_last.setText("Last scope: —") 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) + self.btn_app_unmark_last.setEnabled(target in ("vpn", "direct") and cg_id > 0) def _set_last_scope( self, @@ -1793,6 +1814,28 @@ RU: Применяет policy-rules и проверяет health. При оши return "" return "" + def _cmdline_from_pid(self, pid: int) -> str: + p = int(pid or 0) + if p <= 0: + return "" + try: + with open(f"/proc/{p}/cmdline", "rb") as f: + raw = f.read() or b"" + parts = [x for x in raw.split(b"\x00") if x] + out = " ".join([x.decode("utf-8", errors="replace") for x in parts]) + return out.strip() + except Exception: + return "" + + def _exe_from_pid(self, pid: int) -> str: + p = int(pid or 0) + if p <= 0: + return "" + try: + return os.readlink(f"/proc/{p}/exe").strip() + except Exception: + return "" + def _control_group_for_unit_retry(self, unit: str, *, timeout_sec: float = 2.0) -> str: u = (unit or "").strip() if not u: @@ -1833,6 +1876,61 @@ RU: Применяет policy-rules и проверяет health. При оши ).strip() ) + def on_app_mark_pid(self) -> None: + def work() -> None: + raw = (self.ed_app_pid.text() or "").strip() + if not raw: + QMessageBox.warning(self, "Missing PID", "Please enter a PID first.") + return + try: + pid = int(raw) + except Exception: + QMessageBox.warning(self, "Invalid PID", f"PID must be an integer: {raw!r}") + return + if pid <= 0: + QMessageBox.warning(self, "Invalid PID", f"PID must be > 0: {pid}") + return + + cg = self._cgroup_path_from_pid(pid) + if not cg: + raise RuntimeError(f"failed to read cgroup for pid={pid} (process may not exist)") + + cmdline = self._cmdline_from_pid(pid) or f"pid={pid}" + app_key = self._exe_from_pid(pid) or self._infer_app_key_from_cmdline(cmdline) or f"pid:{pid}" + + target = "vpn" if self.rad_app_vpn.isChecked() else "direct" + ttl_sec = int(self.spn_app_ttl.value()) * 3600 + + self._append_app_log(f"[pid] mark: pid={pid} target={target} ttl={ttl_sec}s") + self._append_app_log(f"[pid] cgroup: {cg}") + if cmdline: + self._append_app_log(f"[pid] cmdline: {cmdline}") + + res = self.ctrl.traffic_appmarks_apply( + op="add", + target=target, + cgroup=cg, + unit="", + command=cmdline, + app_key=app_key, + timeout_sec=ttl_sec, + ) + if not res.ok: + self._append_app_log(f"[pid] ERROR: {res.message}") + self._set_action_status(f"PID mark failed: {res.message}", ok=False) + QMessageBox.critical(self, "Mark PID error", res.message or "mark failed") + return + + self._append_app_log(f"[pid] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s") + self._set_action_status(f"PID marked: target={target} cgroup_id={res.cgroup_id}", ok=True) + self._set_last_scope(unit="", target=target, app_key=app_key, cmdline=cmdline, cgroup_id=int(res.cgroup_id or 0)) + + self.refresh_appmarks_counts() + self.refresh_appmarks_items(quiet=True) + self.refresh_app_profiles(quiet=True) + + self._safe(work, title="Mark PID error") + def on_app_run(self) -> None: def work() -> None: cmdline = (self.ed_app_cmd.text() or "").strip() @@ -2024,14 +2122,14 @@ RU: Применяет policy-rules и проверяет health. При оши unit = (self._last_app_unit or "").strip() target = (self._last_app_target or "").strip().lower() cg_id = int(self._last_app_cgroup_id or 0) - if not unit or target not in ("vpn", "direct") or cg_id <= 0: + if target not in ("vpn", "direct") or cg_id <= 0: return def work() -> None: if QMessageBox.question( self, "Unmark last", - f"Remove routing mark for last scope?\n\nunit={unit}\ntarget={target}\ncgroup_id={cg_id}", + f"Remove routing mark for last item?\n\nunit={unit or '-'}\ntarget={target}\ncgroup_id={cg_id}", ) != QMessageBox.StandardButton.Yes: return