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")
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user