ui: run runtime apps via transient service units

This commit is contained in:
beckline
2026-02-15 03:39:21 +03:00
parent c287e98366
commit e789261e7e

View File

@@ -195,7 +195,7 @@ RU: Восстанавливает маршруты/nft из последнег
self.tabs.addTab(tab_basic, "Traffic basics")
# -----------------------------------------------------------------
# Apps (runtime): systemd --user scope + backend appmarks
# Apps (runtime): systemd --user unit + backend appmarks
# -----------------------------------------------------------------
tab_apps = QWidget()
@@ -203,15 +203,15 @@ RU: Восстанавливает маршруты/nft из последнег
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"
"- Launch uses systemd-run --user (transient unit).\n"
"- Backend adds the unit 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_group = QGroupBox("Run app (systemd unit) + apply mark")
run_layout = QVBoxLayout(run_group)
row_cmd = QHBoxLayout()
@@ -221,8 +221,8 @@ RU: Восстанавливает маршруты/nft из последнег
"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."
"EN: Command line to run. This runs as current user via systemd --user.\n"
"RU: Команда запуска. Запускается от текущего пользователя через systemd --user."
)
row_cmd.addWidget(self.ed_app_cmd, stretch=1)
self.btn_app_pick = QPushButton("Pick app...")
@@ -314,30 +314,30 @@ RU: Восстанавливает маршруты/nft из последнег
tab_apps_layout.addWidget(run_group)
scopes_group = QGroupBox("Active svpn scopes (systemd --user)")
scopes_group = QGroupBox("Active svpn units (systemd --user)")
scopes_layout = QVBoxLayout(scopes_group)
scopes_row = QHBoxLayout()
self.btn_scopes_refresh = QPushButton("Refresh scopes")
self.btn_scopes_refresh = QPushButton("Refresh units")
self.btn_scopes_refresh.setToolTip(
"EN: Refresh list of running svpn-* scopes.\n"
"RU: Обновить список запущенных svpn-* scope."
"EN: Refresh list of running svpn-* units (.service/.scope).\n"
"RU: Обновить список запущенных svpn-* unit (.service/.scope)."
)
self.btn_scopes_refresh.clicked.connect(self.refresh_running_scopes)
scopes_row.addWidget(self.btn_scopes_refresh)
self.btn_scopes_stop_selected = QPushButton("Stop selected")
self.btn_scopes_stop_selected.setToolTip(
"EN: Unmarks + stops selected scopes.\n"
"RU: Удаляет метки + останавливает выбранные scope."
"EN: Unmarks + stops selected units.\n"
"RU: Удаляет метки + останавливает выбранные unit."
)
self.btn_scopes_stop_selected.clicked.connect(self.on_scopes_stop_selected)
scopes_row.addWidget(self.btn_scopes_stop_selected)
self.btn_scopes_cleanup = QPushButton("Cleanup all svpn scopes")
self.btn_scopes_cleanup = QPushButton("Cleanup all svpn units")
self.btn_scopes_cleanup.setToolTip(
"EN: Unmarks + stops ALL running svpn-* scopes.\n"
"RU: Удаляет метки + останавливает ВСЕ запущенные svpn-* scope."
"EN: Unmarks + stops ALL running svpn-* units.\n"
"RU: Удаляет метки + останавливает ВСЕ запущенные svpn-* unit."
)
self.btn_scopes_cleanup.clicked.connect(self.on_scopes_cleanup_all)
scopes_row.addWidget(self.btn_scopes_cleanup)
@@ -348,14 +348,14 @@ RU: Восстанавливает маршруты/nft из последнег
self.lst_scopes = QListWidget()
self.lst_scopes.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.lst_scopes.setToolTip(
"EN: Running svpn scopes. Double click to copy unit name.\n"
"RU: Запущенные svpn scope. Двойной клик копирует имя unit."
"EN: Running svpn units. Double click to copy unit name.\n"
"RU: Запущенные svpn unit. Двойной клик копирует имя unit."
)
self.lst_scopes.itemDoubleClicked.connect(lambda it: self._copy_scope_unit(it))
self.lst_scopes.setFixedHeight(140)
scopes_layout.addWidget(self.lst_scopes)
self.lbl_scopes = QLabel("Running scopes: —")
self.lbl_scopes = QLabel("Running units: —")
self.lbl_scopes.setStyleSheet("color: gray;")
scopes_layout.addWidget(self.lbl_scopes)
@@ -908,7 +908,7 @@ RU: Применяет policy-rules и проверяет health. При оши
self._safe(work, title="App picker error")
def _run_systemd_scope(self, cmdline: str, *, unit: str) -> tuple[str, str]:
def _run_systemd_unit(self, cmdline: str, *, unit: str) -> tuple[str, str]:
args = shlex.split(cmdline or "")
if not args:
raise ValueError("empty command")
@@ -916,8 +916,6 @@ RU: Применяет policy-rules и проверяет health. При оши
run_cmd = [
"systemd-run",
"--user",
"--scope",
"--no-block",
"--unit",
unit,
"--collect",
@@ -946,7 +944,7 @@ RU: Применяет policy-rules и проверяет health. При оши
# EN: may appear/disappear fast. Retry briefly to avoid race.
# RU: Некоторые приложения (например, chrome-wrapper) быстро завершаются; scope
# RU: может появиться/исчезнуть очень быстро. Делаем небольшой retry.
cg = self._control_group_for_unit_retry(unit, timeout_sec=2.0)
cg = self._control_group_for_unit_retry(unit, timeout_sec=3.0)
return cg, out
@@ -999,13 +997,13 @@ RU: Применяет policy-rules и проверяет health. При оши
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"
unit = f"svpn-{target}-{int(time.time())}.service"
self._append_app_log(
f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}"
)
cg, out = self._run_systemd_scope(cmdline, 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}")
@@ -1182,11 +1180,12 @@ RU: Применяет policy-rules и проверяет health. При оши
except subprocess.TimeoutExpired:
return 124, f"timeout running: {' '.join(cmd)}"
def _list_running_svpn_scopes(self) -> list[str]:
def _list_running_svpn_units(self) -> list[str]:
code, out = self._systemctl_user(
[
"list-units",
"--type=scope",
"--type=service",
"--state=running",
"--no-legend",
"--no-pager",
@@ -1205,7 +1204,7 @@ RU: Применяет policy-rules и проверяет health. При оши
if not fields:
continue
unit = fields[0].strip()
if unit.startswith("svpn-") and unit.endswith(".scope"):
if unit.startswith("svpn-") and (unit.endswith(".scope") or unit.endswith(".service")):
units.append(unit)
units.sort()
return units
@@ -1232,7 +1231,7 @@ RU: Применяет policy-rules и проверяет health. При оши
def refresh_running_scopes(self) -> None:
def work() -> None:
units = self._list_running_svpn_scopes()
units = self._list_running_svpn_units()
self.lst_scopes.clear()
for unit in units:
@@ -1262,17 +1261,17 @@ RU: Применяет policy-rules и проверяет health. При оши
f"Target: {target or '-'}\n"
f"ControlGroup: {cg or '-'}\n"
f"cgroup_id: {cg_id or '-'}\n\n"
"EN: Stop selected will unmark by cgroup_id (if known) and stop the scope.\n"
"RU: Stop selected удалит метку по cgroup_id (если известен) и остановит scope."
"EN: Stop selected will unmark by cgroup_id (if known) and stop the unit.\n"
"RU: Stop selected удалит метку по cgroup_id (если известен) и остановит unit."
)
self.lst_scopes.addItem(it)
self.lbl_scopes.setText(f"Running scopes: {len(units)}")
self.lbl_scopes.setText(f"Running units: {len(units)}")
has_any = self.lst_scopes.count() > 0
self.btn_scopes_stop_selected.setEnabled(has_any)
self.btn_scopes_cleanup.setEnabled(has_any)
self._safe(work, title="Scopes refresh error")
self._safe(work, title="Units refresh error")
def _copy_scope_unit(self, it: QListWidgetItem) -> None:
unit = ""
@@ -1287,7 +1286,7 @@ RU: Применяет policy-rules и проверяет health. При оши
QtGui.QGuiApplication.clipboard().setText(unit)
except Exception:
pass
self._append_app_log(f"[scope] copied unit: {unit}")
self._append_app_log(f"[unit] copied unit: {unit}")
self._set_action_status(f"Copied unit: {unit}", ok=True)
def _stop_scope_unit(self, unit: str) -> None:
@@ -1328,47 +1327,47 @@ RU: Применяет policy-rules и проверяет health. При оши
def work() -> None:
infos = self._selected_scope_infos()
if not infos:
QMessageBox.information(self, "No selection", "Select one or more scopes first.")
QMessageBox.information(self, "No selection", "Select one or more units first.")
return
if QMessageBox.question(
self,
"Stop selected",
f"Unmark + stop {len(infos)} selected scope(s)?",
f"Unmark + stop {len(infos)} selected unit(s)?",
) != QMessageBox.StandardButton.Yes:
return
for info in infos:
self._append_app_log(
f"[scope] stop: unit={info.unit} target={info.target} cgroup_id={info.cgroup_id}"
f"[unit] stop: unit={info.unit} target={info.target} cgroup_id={info.cgroup_id}"
)
try:
self._unmark_scope(info)
self._append_app_log("[scope] unmark OK")
self._append_app_log("[unit] unmark OK")
except Exception as e:
self._append_app_log(f"[scope] unmark WARN: {e}")
self._append_app_log(f"[unit] unmark WARN: {e}")
self._stop_scope_unit(info.unit)
self._append_app_log("[scope] stop OK")
self._append_app_log("[unit] stop OK")
self.refresh_running_scopes()
self.refresh_appmarks_counts()
self._set_action_status(f"Stopped scopes: {len(infos)}", ok=True)
self._set_action_status(f"Stopped units: {len(infos)}", ok=True)
self._safe(work, title="Stop selected scopes error")
self._safe(work, title="Stop selected units error")
def on_scopes_cleanup_all(self) -> None:
def work() -> None:
units = self._list_running_svpn_scopes()
units = self._list_running_svpn_units()
if not units:
self._set_action_status("No running svpn scopes", ok=True)
self._set_action_status("No running svpn units", ok=True)
self.refresh_running_scopes()
return
if QMessageBox.question(
self,
"Cleanup all",
f"Unmark + stop ALL running svpn scopes ({len(units)})?",
f"Unmark + stop ALL running svpn units ({len(units)})?",
) != QMessageBox.StandardButton.Yes:
return
@@ -1389,24 +1388,24 @@ RU: Применяет policy-rules и проверяет health. При оши
cgroup_id=int(cg_id or 0),
)
self._append_app_log(
f"[scope] cleanup: unit={info.unit} target={info.target} cgroup_id={info.cgroup_id}"
f"[unit] cleanup: unit={info.unit} target={info.target} cgroup_id={info.cgroup_id}"
)
try:
self._unmark_scope(info)
self._append_app_log("[scope] unmark OK")
self._append_app_log("[unit] unmark OK")
except Exception as e:
self._append_app_log(f"[scope] unmark WARN: {e}")
self._append_app_log(f"[unit] unmark WARN: {e}")
try:
self._stop_scope_unit(info.unit)
stopped += 1
except Exception as e:
self._append_app_log(f"[scope] stop ERROR: {e}")
self._append_app_log(f"[unit] stop ERROR: {e}")
self.refresh_running_scopes()
self.refresh_appmarks_counts()
self._set_action_status(f"Cleanup done: stopped={stopped}/{len(units)}", ok=True)
self._safe(work, title="Cleanup scopes error")
self._safe(work, title="Cleanup units error")
def on_rollback(self) -> None:
def work() -> None: