Harden resolver and expand traffic runtime controls
This commit is contained in:
@@ -1240,6 +1240,20 @@ class ApiClient:
|
||||
attempts: int = 1,
|
||||
concurrency: int = 6,
|
||||
) -> DNSBenchmarkResponse:
|
||||
# Benchmark can legitimately run much longer than the default 5s API timeout.
|
||||
# Estimate a safe read timeout from payload size and cap it to keep UI responsive.
|
||||
upstream_count = len(upstreams or [])
|
||||
domain_count = len(domains or [])
|
||||
if domain_count <= 0:
|
||||
domain_count = 6 # backend default domains
|
||||
clamped_attempts = max(1, min(int(attempts), 3))
|
||||
clamped_concurrency = max(1, min(int(concurrency), 32))
|
||||
if upstream_count <= 0:
|
||||
upstream_count = 1
|
||||
waves = (upstream_count + clamped_concurrency - 1) // clamped_concurrency
|
||||
per_wave_sec = domain_count * clamped_attempts * (max(300, int(timeout_ms)) / 1000.0)
|
||||
bench_timeout = min(180.0, max(15.0, waves*per_wave_sec*1.2+5.0))
|
||||
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(
|
||||
@@ -1253,6 +1267,7 @@ class ApiClient:
|
||||
"attempts": int(attempts),
|
||||
"concurrency": int(concurrency),
|
||||
},
|
||||
timeout=bench_timeout,
|
||||
)
|
||||
)
|
||||
or {},
|
||||
@@ -1412,13 +1427,40 @@ class ApiClient:
|
||||
lines = []
|
||||
return DomainsTable(lines=[str(x) for x in lines])
|
||||
|
||||
def domains_file_get(self, name: Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"]) -> DomainsFile:
|
||||
def domains_file_get(
|
||||
self,
|
||||
name: Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
) -> DomainsFile:
|
||||
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/file", params={"name": name})) or {})
|
||||
content = str(data.get("content") or "")
|
||||
source = str(data.get("source") or "")
|
||||
return DomainsFile(name=name, content=content, source=source)
|
||||
|
||||
def domains_file_set(self, name: Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], content: str) -> None:
|
||||
def domains_file_set(
|
||||
self,
|
||||
name: Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
content: str,
|
||||
) -> None:
|
||||
self._request("POST", "/api/v1/domains/file", json_body={"name": name, "content": content})
|
||||
|
||||
# VPN
|
||||
|
||||
@@ -922,18 +922,65 @@ class DashboardController:
|
||||
|
||||
def domains_file_load(self, name: str) -> DomainsFile:
|
||||
nm = name.strip().lower()
|
||||
if nm not in ("bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"):
|
||||
if nm not in (
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
):
|
||||
raise ValueError(f"Invalid domains file name: {name}")
|
||||
return self.client.domains_file_get(
|
||||
cast(Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], nm)
|
||||
cast(
|
||||
Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
nm,
|
||||
)
|
||||
)
|
||||
|
||||
def domains_file_save(self, name: str, content: str) -> None:
|
||||
nm = name.strip().lower()
|
||||
if nm not in ("bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"):
|
||||
if nm not in (
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
):
|
||||
raise ValueError(f"Invalid domains file name: {name}")
|
||||
self.client.domains_file_set(
|
||||
cast(Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], nm), content
|
||||
cast(
|
||||
Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
nm,
|
||||
),
|
||||
content,
|
||||
)
|
||||
|
||||
# -------- Trace --------
|
||||
|
||||
@@ -105,6 +105,74 @@ def infer_app_key(cmdline: str) -> str:
|
||||
return canonicalize_app_key("", cmdline)
|
||||
|
||||
|
||||
def browser_harden_enabled() -> bool:
|
||||
raw = str(os.environ.get("SVPN_BROWSER_HARDEN", "1") or "1").strip().lower()
|
||||
return raw not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def is_chromium_like_cmd(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
|
||||
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(cmdline: str) -> str:
|
||||
raw = (cmdline or "").strip()
|
||||
if not raw or not browser_harden_enabled():
|
||||
return raw
|
||||
try:
|
||||
toks = shlex.split(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
if not 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 canonicalize_app_key(app_key: str, cmdline: str) -> str:
|
||||
key = (app_key or "").strip()
|
||||
cmd = (cmdline or "").strip()
|
||||
@@ -181,6 +249,19 @@ def systemctl_user(args: list[str], *, timeout: float = 4.0) -> tuple[int, str]:
|
||||
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||
return int(p.returncode or 0), out
|
||||
|
||||
def stop_user_unit_best_effort(unit: str) -> tuple[bool, str]:
|
||||
u = (unit or "").strip()
|
||||
if not u:
|
||||
return False, "empty unit"
|
||||
code, out = systemctl_user(["stop", u], timeout=4.0)
|
||||
if code == 0:
|
||||
return True, out
|
||||
code2, out2 = systemctl_user(["kill", u], timeout=4.0)
|
||||
if code2 == 0:
|
||||
return True, out2
|
||||
msg = (out2 or out or f"stop/kill failed for {u}").strip()
|
||||
return False, msg
|
||||
|
||||
|
||||
def cgroup_path_from_pid(pid: int) -> str:
|
||||
p = int(pid or 0)
|
||||
@@ -246,7 +327,13 @@ def run_systemd_unit(cmdline: str, *, unit: str) -> str:
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(f"systemd-run failed: rc={p.returncode}\n{out}".strip())
|
||||
|
||||
cg = effective_cgroup_for_unit(unit, timeout_sec=3.0)
|
||||
try:
|
||||
cg = effective_cgroup_for_unit(unit, timeout_sec=3.0)
|
||||
except Exception as e:
|
||||
stopped, stop_msg = stop_user_unit_best_effort(unit)
|
||||
if stopped:
|
||||
raise RuntimeError(f"{e}\n\nUnit was stopped (fail-closed): {unit}") from e
|
||||
raise RuntimeError(f"{e}\n\nWARNING: failed to stop unit {unit}: {stop_msg}") from e
|
||||
return cg
|
||||
|
||||
|
||||
@@ -307,7 +394,8 @@ def apply_mark(*, target: str, cgroup: str, unit: str, command: str, app_key: st
|
||||
res = api_request("POST", "/api/v1/traffic/appmarks", json_body=payload, timeout=4.0)
|
||||
if not bool(res.get("ok", False)):
|
||||
raise RuntimeError(f"appmark failed: {res.get('message')}")
|
||||
log(f"mark added: target={target} app={app_key} unit={unit} cgroup_id={res.get('cgroup_id')} ttl={res.get('timeout_sec')}")
|
||||
ttl_txt = "persistent" if int(res.get("timeout_sec", 0) or 0) <= 0 else f"{int(res.get('timeout_sec', 0) or 0)}s"
|
||||
log(f"mark added: target={target} app={app_key} unit={unit} cgroup_id={res.get('cgroup_id')} ttl={ttl_txt}")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
@@ -322,27 +410,36 @@ def main(argv: list[str]) -> int:
|
||||
cmd = str(prof.get("command") or "").strip()
|
||||
if not cmd:
|
||||
raise RuntimeError("profile command is empty")
|
||||
run_cmd = maybe_harden_browser_cmdline(cmd)
|
||||
if run_cmd != cmd:
|
||||
log("browser hardening: added anti-leak flags")
|
||||
target = str(prof.get("target") or "vpn").strip().lower()
|
||||
if target not in ("vpn", "direct"):
|
||||
target = "vpn"
|
||||
|
||||
app_key_raw = str(prof.get("app_key") or "").strip()
|
||||
app_key = canonicalize_app_key(app_key_raw, cmd) or canonicalize_app_key("", cmd)
|
||||
app_key = canonicalize_app_key(app_key_raw, run_cmd) or canonicalize_app_key("", run_cmd)
|
||||
ttl = int(prof.get("ttl_sec", 0) or 0)
|
||||
if ttl <= 0:
|
||||
ttl = 24 * 60 * 60
|
||||
if ttl < 0:
|
||||
ttl = 0
|
||||
|
||||
# Try refresh first if already running.
|
||||
if refresh_if_running(target=target, app_key=app_key, command=cmd, ttl_sec=ttl):
|
||||
if refresh_if_running(target=target, app_key=app_key, command=run_cmd, ttl_sec=ttl):
|
||||
if args.json:
|
||||
print(json.dumps({"ok": True, "op": "refresh", "id": pid, "target": target, "app_key": app_key}))
|
||||
return 0
|
||||
|
||||
unit = f"svpn-{target}-{int(time.time())}.service"
|
||||
log(f"launching profile id={pid} target={target} app={app_key} unit={unit}")
|
||||
cg = run_systemd_unit(cmd, unit=unit)
|
||||
cg = run_systemd_unit(run_cmd, unit=unit)
|
||||
log(f"ControlGroup: {cg}")
|
||||
apply_mark(target=target, cgroup=cg, unit=unit, command=cmd, app_key=app_key, ttl_sec=ttl)
|
||||
try:
|
||||
apply_mark(target=target, cgroup=cg, unit=unit, command=run_cmd, app_key=app_key, ttl_sec=ttl)
|
||||
except Exception as e:
|
||||
stopped, stop_msg = stop_user_unit_best_effort(unit)
|
||||
if stopped:
|
||||
raise RuntimeError(f"{e}\n\nUnit was stopped (fail-closed): {unit}") from e
|
||||
raise RuntimeError(f"{e}\n\nWARNING: failed to stop unit {unit}: {stop_msg}") from e
|
||||
if args.json:
|
||||
print(json.dumps({"ok": True, "op": "run", "id": pid, "target": target, "app_key": app_key, "unit": unit}))
|
||||
return 0
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -511,6 +511,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
"static-ips",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
"smartdns.conf",
|
||||
):
|
||||
QListWidgetItem(name, self.lst_files)
|
||||
@@ -631,6 +632,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
"static-ips": "static",
|
||||
"last-ips-map-direct": "last-ips-map-direct",
|
||||
"last-ips-map-wildcard": "last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts": "wildcard-observed-hosts",
|
||||
"smartdns.conf": "smartdns",
|
||||
}
|
||||
if name in api_map:
|
||||
@@ -643,6 +645,8 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)"
|
||||
elif name == "last-ips-map-wildcard":
|
||||
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)"
|
||||
elif name == "wildcard-observed-hosts":
|
||||
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (derived unique hosts)"
|
||||
else:
|
||||
path = f"/etc/selective-vpn/domains/{name}.txt"
|
||||
return content, source, path
|
||||
@@ -1530,7 +1534,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
def work():
|
||||
name = self._get_selected_domains_file()
|
||||
content, source, path = self._load_file_content(name)
|
||||
is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard")
|
||||
is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard", "wildcard-observed-hosts")
|
||||
self.txt_domains.setReadOnly(is_readonly)
|
||||
self.btn_domains_save.setEnabled(not is_readonly)
|
||||
self._set_text(self.txt_domains, content)
|
||||
|
||||
Reference in New Issue
Block a user