Harden resolver and expand traffic runtime controls

This commit is contained in:
beckline
2026-02-24 00:17:46 +03:00
parent 89eaaf3f23
commit 50518a641d
18 changed files with 2048 additions and 181 deletions

View File

@@ -100,6 +100,8 @@ class TrafficModeDialog(QDialog):
self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0)
except Exception:
self._last_app_cgroup_id = 0
self._adv_auto_local_bypass: bool = True
self._adv_ingress_reply_bypass: bool = False
hint_group = QGroupBox("Mode behavior")
hint_layout = QVBoxLayout(hint_group)
@@ -172,13 +174,32 @@ RU: Обновить список доступных интерфейсов (UP)
row_iface.addStretch(1)
mode_layout.addLayout(row_iface)
self.chk_auto_local = QCheckBox("Auto-local bypass (LAN/container subnets)")
self.chk_auto_local.setToolTip("""EN: Mirrors local/LAN/docker routes from main into agvpn table to prevent breakage in full tunnel.
EN: This does NOT force containers to use direct internet; use Force Direct subnets for that.
RU: Копирует локальные/LAN/docker маршруты из main в agvpn, чтобы не ломалась локалка в full tunnel.
RU: Это НЕ делает контейнеры direct в интернет; для этого используй Force Direct subnets.""")
self.chk_auto_local.stateChanged.connect(lambda _state: self.on_auto_local_toggle())
mode_layout.addWidget(self.chk_auto_local)
row_adv_button = QHBoxLayout()
self.btn_adv_bypass = QPushButton("Advanced bypass...")
self.btn_adv_bypass.setToolTip(
"EN: Open compact Full tunnel advanced bypass settings (auto-local + ingress-reply).\n"
"RU: Открыть компактные расширенные bypass-настройки Full tunnel (auto-local + ingress-reply)."
)
self.btn_adv_bypass.clicked.connect(self.on_open_advanced_bypass_dialog)
row_adv_button.addWidget(self.btn_adv_bypass)
self.btn_mode_checklist = QPushButton("Checklist...")
self.btn_mode_checklist.setToolTip(
"EN: Quick production checklist for traffic mode/full tunnel safety.\n"
"RU: Короткий боевой чеклист по режимам трафика и безопасному full tunnel."
)
self.btn_mode_checklist.clicked.connect(self.on_show_mode_checklist)
row_adv_button.addWidget(self.btn_mode_checklist)
self.lbl_adv_quick = QLabel("Advanced bypass: —")
self.lbl_adv_quick.setToolTip(
"EN: Saved and active state for Full tunnel advanced bypass.\n"
"RU: Сохраненное и активное состояние advanced bypass для Full tunnel."
)
self.lbl_adv_quick.setStyleSheet("color: gray;")
row_adv_button.addWidget(self.lbl_adv_quick, stretch=1)
row_adv_button.addStretch(1)
mode_layout.addLayout(row_adv_button)
self.lbl_state = QLabel("Traffic mode: —")
self.lbl_state.setStyleSheet("color: gray;")
@@ -371,6 +392,17 @@ RU: Восстанавливает маршруты/nft из последнег
row_cmd.addWidget(self.btn_app_pick)
run_layout.addLayout(row_cmd)
row_harden = QHBoxLayout()
self.chk_app_browser_harden = QCheckBox("Browser anti-leak flags (WebRTC/QUIC)")
self.chk_app_browser_harden.setChecked(True)
self.chk_app_browser_harden.setToolTip(
"EN: For Chromium-family browsers, auto-add flags to reduce WebRTC/STUN and QUIC leaks.\n"
"RU: Для Chromium-подобных браузеров автоматически добавляет флаги против утечек WebRTC/STUN и QUIC."
)
row_harden.addWidget(self.chk_app_browser_harden)
row_harden.addStretch(1)
run_layout.addLayout(row_harden)
row_target = QHBoxLayout()
row_target.addWidget(QLabel("Route via"))
self.rad_app_vpn = QRadioButton("VPN")
@@ -393,10 +425,20 @@ RU: Восстанавливает маршруты/nft из последнег
run_layout.addLayout(row_target)
row_ttl = QHBoxLayout()
self.chk_app_temporary = QCheckBox("Temporary mark (TTL)")
self.chk_app_temporary.setToolTip(
"EN: Off (default): mark is persistent until manual unmark/clear.\n"
"EN: On: mark expires after TTL hours.\n"
"RU: Выкл (по умолчанию): метка постоянная до ручного удаления.\n"
"RU: Вкл: метка истекает через TTL часов."
)
self.chk_app_temporary.setChecked(False)
row_ttl.addWidget(self.chk_app_temporary)
row_ttl.addWidget(QLabel("TTL (hours)"))
self.spn_app_ttl = QSpinBox()
self.spn_app_ttl.setRange(1, 24 * 30) # up to ~30 days
self.spn_app_ttl.setValue(24)
self.spn_app_ttl.setEnabled(False)
self.spn_app_ttl.setToolTip(
"EN: How long the runtime mark stays active (backend nftset element timeout).\n"
"RU: Сколько живет runtime-метка (timeout элемента в nftset)."
@@ -404,6 +446,7 @@ RU: Восстанавливает маршруты/nft из последнег
row_ttl.addWidget(self.spn_app_ttl)
row_ttl.addStretch(1)
run_layout.addLayout(row_ttl)
self.chk_app_temporary.toggled.connect(self.spn_app_ttl.setEnabled)
pid_group = QGroupBox("Mark existing PID (no launch)")
pid_layout = QHBoxLayout(pid_group)
@@ -483,7 +526,7 @@ RU: Восстанавливает маршруты/nft из последнег
tab_run_layout.addStretch(1)
self.apps_tabs.addTab(tab_run, "Run")
marks_group = QGroupBox("Active runtime marks (TTL)")
marks_group = QGroupBox("Active runtime marks")
marks_layout = QVBoxLayout(marks_group)
marks_row = QHBoxLayout()
@@ -509,8 +552,8 @@ RU: Восстанавливает маршруты/nft из последнег
self.lst_marks = QListWidget()
self.lst_marks.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.lst_marks.setToolTip(
"EN: Active runtime marks. Stored by backend with TTL.\n"
"RU: Активные runtime-метки. Хранятся backend с TTL."
"EN: Active runtime marks. Can be persistent or temporary (TTL).\n"
"RU: Активные runtime-метки. Могут быть постоянными или временными (TTL)."
)
self.lst_marks.setFixedHeight(140)
marks_layout.addWidget(self.lst_marks)
@@ -598,6 +641,14 @@ RU: Восстанавливает маршруты/nft из последнег
tab_adv = QWidget()
tab_adv_layout = QVBoxLayout(tab_adv)
adv_hint = QLabel(
"Policy overrides are source-based rules (subnet/UID/cgroup).\n"
"Full tunnel advanced bypass (auto-local + ingress-reply) is configured from Traffic basics."
)
adv_hint.setWordWrap(True)
adv_hint.setStyleSheet("color: gray;")
tab_adv_layout.addWidget(adv_hint)
self.ed_vpn_subnets = QPlainTextEdit()
self.ed_vpn_subnets.setToolTip("""EN: Force VPN by source subnet. Useful for docker subnets when you want containers via VPN.
RU: Принудительно через VPN по source subnet. Полезно для docker-подсетей, если хочешь контейнеры через VPN.""")
@@ -878,12 +929,18 @@ RU: Применяет policy-rules и проверяет health. При оши
desired_mode: str,
applied_mode: str,
preferred_iface: str,
advanced_active: bool,
auto_local_bypass: bool,
auto_local_active: bool,
ingress_reply_bypass: bool,
ingress_reply_active: bool,
bypass_candidates: int,
overrides_applied: int,
cgroup_resolved_uids: int,
cgroup_warning: str,
healthy: bool,
ingress_rule_present: bool,
ingress_nft_active: bool,
probe_ok: bool,
probe_message: str,
active_iface: str,
@@ -903,10 +960,16 @@ RU: Применяет policy-rules и проверяет health. При оши
text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]"
diag_parts = []
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
diag_parts.append(f"advanced={'on' if advanced_active else 'off'}")
diag_parts.append(
f"auto_local_bypass={'on' if auto_local_bypass else 'off'}"
f"auto_local={'on' if auto_local_bypass else 'off'}"
f"({'active' if auto_local_active else 'saved'})"
)
if bypass_candidates > 0:
diag_parts.append(
f"ingress_reply={'on' if ingress_reply_bypass else 'off'}"
f"({'active' if ingress_reply_active else 'saved'})"
)
if auto_local_active and bypass_candidates > 0:
diag_parts.append(f"bypass_routes={bypass_candidates}")
diag_parts.append(f"overrides={overrides_applied}")
if cgroup_resolved_uids > 0:
@@ -917,6 +980,10 @@ RU: Применяет policy-rules и проверяет health. При оши
diag_parts.append(f"iface={active_iface}")
if iface_reason:
diag_parts.append(f"source={iface_reason}")
diag_parts.append(
f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}"
f"/nft:{'ok' if ingress_nft_active else 'off'}"
)
diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}")
if probe_message:
diag_parts.append(probe_message)
@@ -929,6 +996,20 @@ RU: Применяет policy-rules и проверяет health. При оши
self.lbl_diag.setText(diag)
self.lbl_diag.setStyleSheet("color: gray;")
quick = (
f"Advanced bypass: auto-local={'on' if auto_local_bypass else 'off'} "
f"({('active' if auto_local_active else 'saved')}), "
f"ingress-reply={'on' if ingress_reply_bypass else 'off'} "
f"({('active' if ingress_reply_active else 'saved')})"
)
if advanced_active:
adv_color = "green" if (ingress_reply_active or auto_local_active) else "gray"
self.lbl_adv_quick.setText(quick)
self.lbl_adv_quick.setStyleSheet(f"color: {adv_color};")
else:
self.lbl_adv_quick.setText(f"{quick} | applies only in Full tunnel")
self.lbl_adv_quick.setStyleSheet("color: gray;")
def refresh_state(self) -> None:
def work() -> None:
view = self.ctrl.traffic_mode_view()
@@ -946,9 +1027,9 @@ RU: Применяет policy-rules и проверяет health. При оши
opts = self.ctrl.traffic_interfaces()
self._set_preferred_iface_options(opts, view.preferred_iface)
self.chk_auto_local.blockSignals(True)
self.chk_auto_local.setChecked(bool(view.auto_local_bypass))
self.chk_auto_local.blockSignals(False)
self._set_full_tunnel_advanced_enabled(mode)
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass)
self._set_lines(self.ed_vpn_subnets, list(view.force_vpn_subnets or []))
self._set_lines(self.ed_vpn_uids, list(view.force_vpn_uids or []))
self._set_lines(self.ed_vpn_cgroups, list(view.force_vpn_cgroups or []))
@@ -960,12 +1041,18 @@ RU: Применяет policy-rules и проверяет health. При оши
view.desired_mode,
view.applied_mode,
view.preferred_iface,
bool(view.advanced_active),
bool(view.auto_local_bypass),
bool(view.auto_local_active),
bool(view.ingress_reply_bypass),
bool(view.ingress_reply_active),
int(view.bypass_candidates),
int(view.overrides_applied),
int(view.cgroup_resolved_uids),
view.cgroup_warning,
bool(view.healthy),
bool(view.ingress_rule_present),
bool(view.ingress_nft_active),
bool(view.probe_ok),
view.probe_message,
view.active_iface,
@@ -981,13 +1068,15 @@ RU: Применяет policy-rules и проверяет health. При оши
def work() -> None:
preferred = self._preferred_iface_value()
auto_local = self.chk_auto_local.isChecked()
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local)
auto_local = bool(self._adv_auto_local_bypass)
ingress_reply = bool(self._adv_ingress_reply_bypass)
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply)
msg = (
f"Traffic mode set: desired={view.desired_mode}, "
f"applied={view.applied_mode}, iface={view.active_iface or '-'}, "
f"preferred={preferred or 'auto'}, probe_ok={view.probe_ok}, "
f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, "
f"ingress_reply_bypass={view.ingress_reply_bypass}, ingress_reply_active={view.ingress_reply_active}, "
f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}"
)
@@ -1027,35 +1116,158 @@ RU: Применяет policy-rules и проверяет health. При оши
return "direct"
return "selective"
def on_auto_local_toggle(self) -> None:
def _set_full_tunnel_advanced_enabled(self, mode: str) -> None:
is_full = (mode or "").strip().lower() == "full_tunnel"
self.btn_adv_bypass.setEnabled(True)
if is_full:
self.btn_adv_bypass.setText("Advanced bypass...")
self.btn_adv_bypass.setStyleSheet("")
else:
self.btn_adv_bypass.setText("Advanced bypass... (saved only)")
self.btn_adv_bypass.setStyleSheet("color: gray;")
def on_show_mode_checklist(self) -> None:
text = (
"Quick checklist\n\n"
"1) Select mode:\n"
"- Selective: safest default for mixed host/server workloads.\n"
"- Full tunnel: all traffic via VPN (then review advanced bypass).\n"
"- Direct: VPN policy rules disabled.\n\n"
"2) For Full tunnel:\n"
"- Open Advanced bypass.\n"
"- Enable Auto-local bypass for LAN/container reachability.\n"
"- Enable Ingress-reply bypass to keep public services reachable.\n\n"
"3) Verify status line:\n"
"- health must be [OK].\n"
"- ingress_diag should be rule:ok/nft:ok when ingress-reply is ON.\n\n"
"4) If something breaks:\n"
"- Use Advanced bypass -> Reset bypass.\n"
"- Or switch back to Selective and re-test."
)
QMessageBox.information(self, "Traffic mode checklist", text)
def on_open_advanced_bypass_dialog(self) -> None:
mode = self._selected_mode()
dlg = QDialog(self)
dlg.setWindowTitle("Advanced bypass (Full tunnel)")
dlg.setModal(True)
layout = QVBoxLayout(dlg)
hint = QLabel(
"Applies only in Full tunnel.\n"
"- Auto-local bypass: keep LAN/docker reachable.\n"
"- Ingress-reply bypass: keep inbound/public services reachable."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
layout.addWidget(hint)
chk_auto = QCheckBox("Auto-local bypass (LAN/container subnets)")
chk_auto.setChecked(bool(self._adv_auto_local_bypass))
chk_auto.setToolTip(
"EN: Keeps LAN/container routes direct in Full tunnel.\n"
"RU: Сохраняет LAN/контейнерные маршруты direct в Full tunnel."
)
layout.addWidget(chk_auto)
chk_ingress = QCheckBox("Ingress-reply bypass (keep public services reachable)")
chk_ingress.setChecked(bool(self._adv_ingress_reply_bypass))
chk_ingress.setToolTip(
"EN: Keeps replies for inbound WAN connections on main/direct route.\n"
"RU: Оставляет ответы на входящие WAN-соединения по main/direct."
)
layout.addWidget(chk_ingress)
state = QLabel(
"Current mode is Full tunnel: changes apply now."
if mode == "full_tunnel"
else "Current mode is not Full tunnel: changes are saved and applied later."
)
state.setWordWrap(True)
state.setStyleSheet("color: green;" if mode == "full_tunnel" else "color: #b07f00;")
layout.addWidget(state)
reset_note = QLabel(
"Reset bypass = disable both toggles and apply to current mode."
)
reset_note.setWordWrap(True)
reset_note.setStyleSheet("color: gray;")
layout.addWidget(reset_note)
row = QHBoxLayout()
row.addStretch(1)
btn_cancel = QPushButton("Cancel")
btn_reset = QPushButton("Reset bypass")
btn_apply = QPushButton("Apply")
row.addWidget(btn_cancel)
row.addWidget(btn_reset)
row.addWidget(btn_apply)
layout.addLayout(row)
btn_cancel.clicked.connect(dlg.reject)
btn_apply.clicked.connect(dlg.accept)
action = {"mode": "apply"}
def on_reset_click() -> None:
action["mode"] = "reset"
dlg.accept()
btn_reset.clicked.connect(on_reset_click)
if dlg.exec() != QDialog.Accepted:
return
def work() -> None:
mode = self._selected_mode()
if action["mode"] == "reset":
view = self.ctrl.traffic_advanced_reset()
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass)
self._emit_log(
"Traffic advanced bypass reset: "
f"mode={view.desired_mode}, auto_local={view.auto_local_bypass}, "
f"ingress_reply={view.ingress_reply_bypass}, message={view.message}"
)
op_ok = bool(view.healthy) and not self._is_operation_error(view.message)
self._set_action_status(
f"Advanced bypass reset ({view.message})",
ok=op_ok,
)
self.refresh_state()
if self.refresh_cb:
self.refresh_cb()
return
auto_local = bool(chk_auto.isChecked())
ingress_reply = bool(chk_ingress.isChecked())
preferred = self._preferred_iface_value()
auto_local = self.chk_auto_local.isChecked()
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local)
msg = (
f"Traffic auto-local set: mode={view.desired_mode}, "
f"auto_local_bypass={view.auto_local_bypass}, "
f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}"
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply)
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass)
self._emit_log(
"Traffic advanced bypass set: "
f"mode={view.desired_mode}, auto_local={view.auto_local_bypass}, "
f"ingress_reply={view.ingress_reply_bypass}, ingress_active={view.ingress_reply_active}, "
f"message={view.message}"
)
self._emit_log(msg)
op_ok = bool(view.healthy) and not self._is_operation_error(view.message)
self._set_action_status(
f"Auto-local bypass set: {'on' if view.auto_local_bypass else 'off'} ({view.message})",
"Advanced bypass saved: "
f"auto_local={'on' if view.auto_local_bypass else 'off'}, "
f"ingress_reply={'on' if view.ingress_reply_bypass else 'off'} ({view.message})",
ok=op_ok,
)
self.refresh_state()
if self.refresh_cb:
self.refresh_cb()
self._safe(work, title="Auto-local bypass error")
self._safe(work, title="Advanced bypass error")
def on_apply_overrides(self) -> None:
def work() -> None:
mode = self._selected_mode()
preferred = self._preferred_iface_value()
auto_local = self.chk_auto_local.isChecked()
auto_local = bool(self._adv_auto_local_bypass)
ingress_reply = bool(self._adv_ingress_reply_bypass)
vpn_subnets = self._lines_from_text(self.ed_vpn_subnets.toPlainText())
vpn_uids = self._lines_from_text(self.ed_vpn_uids.toPlainText())
vpn_cgroups = self._lines_from_text(self.ed_vpn_cgroups.toPlainText())
@@ -1067,6 +1279,7 @@ RU: Применяет policy-rules и проверяет health. При оши
mode,
preferred,
auto_local,
ingress_reply,
vpn_subnets,
vpn_uids,
vpn_cgroups,
@@ -1076,6 +1289,7 @@ RU: Применяет policy-rules и проверяет health. При оши
)
msg = (
f"Traffic overrides applied: mode={view.desired_mode}, "
f"auto_local={view.auto_local_bypass}, ingress_reply={view.ingress_reply_bypass}, ingress_active={view.ingress_reply_active}, "
f"vpn_subnets={len(view.force_vpn_subnets)}, vpn_uids={len(view.force_vpn_uids)}, vpn_cgroups={len(view.force_vpn_cgroups)}, "
f"direct_subnets={len(view.force_direct_subnets)}, direct_uids={len(view.force_direct_uids)}, direct_cgroups={len(view.force_direct_cgroups)}, "
f"overrides={view.overrides_applied}, cgroup_uids={view.cgroup_resolved_uids}, "
@@ -1177,6 +1391,77 @@ RU: Применяет policy-rules и проверяет health. При оши
return primary
def _ui_runtime_mark_ttl_sec(self) -> int:
if bool(getattr(self, "chk_app_temporary", None)) and self.chk_app_temporary.isChecked():
return int(self.spn_app_ttl.value()) * 3600
return 0
def _is_chromium_like_cmd(self, tokens: list[str]) -> bool:
toks = [str(x or "").strip() for x in (tokens or []) if str(x or "").strip()]
if not toks:
return False
exe = os.path.basename(toks[0]).lower()
known = {
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"microsoft-edge",
"microsoft-edge-stable",
"brave",
"brave-browser",
"opera",
"opera-beta",
"opera-developer",
"vivaldi",
"vivaldi-stable",
}
if exe in known:
return True
if any(x in exe for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi")):
return True
# flatpak run <appid>
if exe == "flatpak":
for i, t in enumerate(toks):
if t == "run":
for cand in toks[i + 1:]:
c = cand.strip().lower()
if not c or c.startswith("-") or c == "--":
continue
return any(x in c for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi"))
break
return False
def _maybe_harden_browser_cmdline(self, cmdline: str) -> str:
raw = (cmdline or "").strip()
if not raw:
return raw
if not bool(getattr(self, "chk_app_browser_harden", None)) or not self.chk_app_browser_harden.isChecked():
return raw
try:
toks = shlex.split(raw)
except Exception:
return raw
if not self._is_chromium_like_cmd(toks):
return raw
flags = [
"--disable-quic",
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
]
low = [t.lower() for t in toks]
changed = False
for fl in flags:
fl_low = fl.lower()
if any(t == fl_low or t.startswith(fl_low + "=") for t in low):
continue
toks.append(fl)
changed = True
if not changed:
return raw
return " ".join(shlex.quote(t) for t in toks)
def _launch_and_mark(
self,
*,
@@ -1193,8 +1478,12 @@ RU: Применяет policy-rules и проверяет health. При оши
raise ValueError("invalid target")
ttl = int(ttl_sec or 0)
if ttl <= 0:
ttl = int(self.spn_app_ttl.value()) * 3600
ttl = self._ui_runtime_mark_ttl_sec()
ttl_log = "persistent" if ttl <= 0 else f"{ttl}s"
key = (app_key or "").strip() or self._infer_app_key_from_cmdline(cmdline)
run_cmdline = self._maybe_harden_browser_cmdline(cmdline)
if run_cmdline != cmdline:
self._append_app_log("[app] browser hardening: added anti-leak flags")
# EN: If we already have a running unit for the same app_key+target, refresh mark instead of spawning.
# RU: Если уже есть запущенный unit для того же app_key+target — обновляем метку, не плодим инстансы.
@@ -1221,7 +1510,7 @@ RU: Применяет policy-rules и проверяет health. При оши
target=tgt,
cgroup=cg,
unit=unit,
command=cmdline,
command=run_cmdline,
app_key=key,
timeout_sec=ttl,
)
@@ -1235,7 +1524,7 @@ RU: Применяет policy-rules и проверяет health. При оши
unit=unit,
target=tgt,
app_key=key,
cmdline=cmdline,
cmdline=run_cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
self.refresh_appmarks_items(quiet=True)
@@ -1245,42 +1534,59 @@ RU: Применяет policy-rules и проверяет health. При оши
return
unit = f"svpn-{tgt}-{int(time.time())}.service"
self._append_app_log(f"[app] launching: app={key or '-'} target={tgt} ttl={ttl}s unit={unit}")
cg, out = self._run_systemd_unit(cmdline, unit=unit)
self._append_app_log(f"[app] launching: app={key or '-'} target={tgt} ttl={ttl_log} unit={unit}")
try:
cg, out = self._run_systemd_unit(run_cmdline, unit=unit)
except Exception as e:
try:
self._stop_scope_unit(unit)
self._append_app_log(f"[app] fail-closed: stopped unit after launch error: {unit}")
except Exception as stop_err:
self._append_app_log(f"[app] fail-closed WARN: stop failed for {unit}: {stop_err}")
raise RuntimeError(f"{e}\n\nUnit: {unit}") from e
if out:
self._append_app_log(f"[app] systemd-run:\n{out}")
self._append_app_log(f"[app] ControlGroup: {cg}")
self._set_last_scope(unit=unit, target=tgt, app_key=key, cmdline=cmdline, cgroup_id=0)
res = self.ctrl.traffic_appmarks_apply(
op="add",
target=tgt,
cgroup=cg,
unit=unit,
command=cmdline,
command=run_cmdline,
app_key=key,
timeout_sec=ttl,
)
if not res.ok:
stop_note = ""
try:
self._stop_scope_unit(unit)
self._append_app_log(f"[app] fail-closed: stopped unit after mark failure: {unit}")
stop_note = "\n\nUnit was stopped (fail-closed)."
except Exception as stop_err:
self._append_app_log(f"[app] fail-closed WARN: stop failed for {unit}: {stop_err}")
stop_note = f"\n\nWARNING: failed to stop unit after mark error: {stop_err}"
low = (res.message or "").lower()
if "cgroupv2 path fails" in low or "no such file or directory" in low:
raise RuntimeError(
(res.message or "appmark apply failed")
+ stop_note
+ "\n\n"
+ "EN: This usually means the app didn't stay inside the new systemd unit "
+ "(often because it was already running). Close the app completely and run again.\n"
+ "RU: Обычно это значит, что приложение не осталось в новом systemd unit "
+ "(часто потому что оно уже было запущено). Полностью закрой приложение и запусти снова."
)
raise RuntimeError(res.message or "appmark apply failed")
raise RuntimeError((res.message or "appmark apply failed") + stop_note)
self._append_app_log(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s")
timeout_txt = "persistent" if int(res.timeout_sec or 0) <= 0 else f"{res.timeout_sec}s"
self._append_app_log(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={timeout_txt}")
self._set_action_status(f"App mark added: target={tgt} cgroup_id={res.cgroup_id}", ok=True)
self._set_last_scope(
unit=unit,
target=tgt,
app_key=key,
cmdline=cmdline,
cmdline=run_cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
self.refresh_appmarks_items(quiet=True)
@@ -1322,7 +1628,7 @@ RU: Применяет policy-rules и проверяет health. При оши
script = os.path.abspath(os.path.join(os.path.dirname(__file__), "svpn_run_profile.py"))
# Use env python3 so the shortcut works even if python3 is not /usr/bin/python3.
exec_line = f"/usr/bin/env python3 {script} --id {pid}"
exec_line = f"/usr/bin/env SVPN_BROWSER_HARDEN=1 python3 {script} --id {pid}"
# Keep .desktop content ASCII-ish. Values are UTF-8-safe by spec, but avoid surprises.
name_safe = (name or "SVPN profile").replace("\n", " ").replace("\r", " ").strip()
@@ -1392,6 +1698,7 @@ RU: Применяет policy-rules и проверяет health. При оши
app_key = (getattr(p, "app_key", "") or "").strip()
cmd = (getattr(p, "command", "") or "").strip()
ttl_sec = int(getattr(p, "ttl_sec", 0) or 0)
ttl_txt = "persistent" if ttl_sec <= 0 else f"{ttl_sec}s"
label = name or pid or "(unnamed)"
if target in ("vpn", "direct"):
@@ -1434,7 +1741,7 @@ RU: Применяет policy-rules и проверяет health. При оши
f"id: {pid}\n"
f"app_key: {app_key}\n"
f"target: {target}\n"
f"ttl: {ttl_sec}s\n\n"
f"ttl: {ttl_txt}\n\n"
f"shortcut: {sc_state}\n"
f"shortcut_path: {sc_path}\n\n"
f"runtime_marks: {len(items)}\n"
@@ -1468,7 +1775,7 @@ RU: Применяет policy-rules и проверяет health. При оши
return
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
ttl_sec = self._ui_runtime_mark_ttl_sec()
name = (self.ed_app_profile_name.text() or "").strip()
app_key = self._infer_app_key_from_cmdline(cmdline)
@@ -1585,6 +1892,9 @@ RU: Применяет policy-rules и проверяет health. При оши
# UI uses hours; round up.
hours = max(1, (ttl_sec + 3599) // 3600)
self.spn_app_ttl.setValue(int(hours))
self.chk_app_temporary.setChecked(True)
else:
self.chk_app_temporary.setChecked(False)
self.ed_app_profile_name.setText(name)
self._set_action_status("Profile loaded into form", ok=True)
@@ -1675,13 +1985,15 @@ RU: Применяет policy-rules и проверяет health. При оши
unit = (getattr(it, "unit", "") or "").strip()
cmd = (getattr(it, "command", "") or "").strip()
rem = int(getattr(it, "remaining_sec", 0) or 0)
if rem < 0:
rem_txt = "persistent"
else:
rem_h = rem // 3600
rem_m = (rem % 3600) // 60
rem_s = rem % 60
rem_txt = f"ttl {rem_h:02d}:{rem_m:02d}:{rem_s:02d}"
rem_h = rem // 3600
rem_m = (rem % 3600) // 60
rem_s = rem % 60
rem_txt = f"{rem_h:02d}:{rem_m:02d}:{rem_s:02d}"
label = f"{tgt} {app_key or unit or mid} (ttl {rem_txt})"
label = f"{tgt} {app_key or unit or mid} ({rem_txt})"
q = QListWidgetItem(label)
q.setToolTip(
(
@@ -1689,7 +2001,7 @@ RU: Применяет policy-rules и проверяет health. При оши
f"target: {tgt}\n"
f"app_key: {app_key}\n"
f"unit: {unit}\n"
f"remaining: {rem}s\n\n"
f"remaining: {('persistent' if rem < 0 else str(rem) + 's')}\n\n"
f"{cmd}"
).strip()
)
@@ -2033,9 +2345,10 @@ RU: Применяет policy-rules и проверяет health. При оши
app_key = f"pid:{pid}"
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
ttl_sec = self._ui_runtime_mark_ttl_sec()
self._append_app_log(f"[pid] mark: pid={pid} target={target} ttl={ttl_sec}s")
ttl_txt = "persistent" if ttl_sec <= 0 else f"{ttl_sec}s"
self._append_app_log(f"[pid] mark: pid={pid} target={target} ttl={ttl_txt}")
self._append_app_log(f"[pid] cgroup: {cg}")
if cmdline:
self._append_app_log(f"[pid] cmdline: {cmdline}")
@@ -2055,7 +2368,8 @@ RU: Применяет policy-rules и проверяет health. При оши
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")
res_timeout_txt = "persistent" if int(res.timeout_sec or 0) <= 0 else f"{res.timeout_sec}s"
self._append_app_log(f"[pid] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res_timeout_txt}")
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))
@@ -2073,7 +2387,7 @@ RU: Применяет policy-rules и проверяет health. При оши
return
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
ttl_sec = self._ui_runtime_mark_ttl_sec()
app_key = self._infer_app_key_from_cmdline(cmdline)
self._launch_and_mark(cmdline=cmdline, target=target, ttl_sec=ttl_sec, app_key=app_key)