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