ui: run runtime apps via transient service units
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user