Files
elmprodvpn/selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py

1458 lines
68 KiB
Python

from __future__ import annotations
import ipaddress
from types import SimpleNamespace
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QMessageBox, QTableWidgetItem
from api_client import ApiError, TransportPolicyIntent
from netns_debug import apply_singbox_netns_toggle
class SingBoxRuntimeTransportMixin:
def _update_transport_engine_view(self) -> None:
selected = self._selected_transport_client()
supported = bool(self._transport_api_supported)
has_selected = selected is not None
policy_supported = bool(supported)
multiif_refresh_supported = bool(supported)
self.btn_singbox_profile_create.setEnabled(supported)
self.btn_transport_engine_provision.setEnabled(supported and has_selected)
self.btn_transport_engine_toggle.setEnabled(supported and has_selected)
self.btn_transport_engine_restart.setEnabled(supported and has_selected)
self.btn_transport_engine_rollback.setEnabled(supported)
self.btn_transport_netns_toggle.setEnabled(supported and bool(self._transport_clients))
self.btn_singbox_profile_preview.setEnabled(supported and has_selected)
self.btn_singbox_profile_validate.setEnabled(supported and has_selected)
self.btn_singbox_profile_apply.setEnabled(supported and has_selected)
self.btn_singbox_profile_rollback.setEnabled(supported and has_selected)
self.btn_singbox_profile_history.setEnabled(supported and has_selected)
self.btn_singbox_profile_save.setEnabled(supported and has_selected)
self.btn_singbox_owner_locks_refresh.setEnabled(multiif_refresh_supported)
self.btn_singbox_owner_locks_clear.setEnabled(policy_supported)
self.btn_singbox_policy_add.setEnabled(policy_supported)
self.btn_singbox_policy_load_selected.setEnabled(policy_supported)
self.btn_singbox_policy_update_selected.setEnabled(policy_supported)
self.btn_singbox_policy_use_template.setEnabled(policy_supported)
self.btn_singbox_policy_add_demo.setEnabled(policy_supported)
self.btn_singbox_policy_remove.setEnabled(policy_supported)
self.btn_singbox_policy_reload.setEnabled(policy_supported)
self.btn_singbox_policy_validate.setEnabled(policy_supported)
self.btn_singbox_policy_apply.setEnabled(policy_supported)
self.btn_singbox_policy_rollback.setEnabled(policy_supported)
self.cmb_singbox_policy_selector_type.setEnabled(policy_supported)
self.cmb_singbox_policy_template.setEnabled(policy_supported)
self.ent_singbox_policy_selector_value.setEnabled(policy_supported)
self.cmb_singbox_policy_client_id.setEnabled(policy_supported)
self.cmb_singbox_policy_mode.setEnabled(policy_supported)
self.spn_singbox_policy_priority.setEnabled(policy_supported)
if hasattr(self, "ent_singbox_owner_lock_client"):
self.ent_singbox_owner_lock_client.setEnabled(policy_supported)
if hasattr(self, "ent_singbox_owner_lock_destination"):
self.ent_singbox_owner_lock_destination.setEnabled(policy_supported)
if hasattr(self, "cmb_singbox_owner_engine_scope"):
self.cmb_singbox_owner_engine_scope.setEnabled(True)
self._refresh_transport_netns_toggle_button()
if not supported:
self._clear_singbox_editor()
self._set_singbox_editor_enabled(False)
self.lbl_transport_selected_engine.setText("Selected profile: API unavailable")
self.lbl_transport_selected_engine.setStyleSheet("color: orange;")
self.btn_transport_engine_toggle.blockSignals(True)
self.btn_transport_engine_toggle.setChecked(False)
self.btn_transport_engine_toggle.blockSignals(False)
self.btn_transport_engine_toggle.setText("Disconnected")
self.btn_transport_engine_toggle.setStyleSheet("color: gray;")
self.lbl_transport_engine_meta.setText("Engine: transport API is unavailable on current backend")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
self.lbl_singbox_profile_name.setText("Profile: —")
self.lbl_singbox_profile_name.setStyleSheet("color: gray;")
self.lbl_singbox_metric_conn_value.setText("API unavailable")
self.lbl_singbox_metric_conn_sub.setText("Transport endpoints are not exposed")
self.lbl_singbox_metric_profile_value.setText("")
self.lbl_singbox_metric_profile_sub.setText("")
self.lbl_singbox_metric_proto_value.setText("")
self.lbl_singbox_metric_proto_sub.setText("")
self.lbl_singbox_metric_policy_value.setText("")
self.lbl_singbox_metric_policy_sub.setText("")
return
if selected is None:
self._clear_singbox_editor()
self._set_singbox_editor_enabled(False)
self.lbl_transport_selected_engine.setText("Selected profile: —")
self.lbl_transport_selected_engine.setStyleSheet("color: gray;")
self.btn_transport_engine_toggle.blockSignals(True)
self.btn_transport_engine_toggle.setChecked(False)
self.btn_transport_engine_toggle.blockSignals(False)
self.btn_transport_engine_toggle.setText("Disconnected")
self.btn_transport_engine_toggle.setStyleSheet("color: gray;")
self.lbl_transport_engine_meta.setText("Engine: no SingBox clients configured")
self.lbl_transport_engine_meta.setStyleSheet("color: gray;")
self.lbl_singbox_profile_name.setText("Profile: —")
self.lbl_singbox_profile_name.setStyleSheet("color: gray;")
self.lbl_singbox_metric_conn_value.setText("Not selected")
self.lbl_singbox_metric_conn_sub.setText("Choose profile card below")
self.lbl_singbox_metric_profile_value.setText("")
self.lbl_singbox_metric_profile_sub.setText("")
self.lbl_singbox_metric_proto_value.setText("")
self.lbl_singbox_metric_proto_sub.setText("")
self.lbl_singbox_metric_policy_value.setText("")
self.lbl_singbox_metric_policy_sub.setText("")
return
status, latency, last_error, last_check = self._transport_live_health_for_client(selected)
kind = str(getattr(selected, "kind", "") or "").strip().lower() or "engine"
cid = str(getattr(selected, "id", "") or "").strip()
iface = str(getattr(selected, "iface", "") or "").strip()
table = str(getattr(selected, "routing_table", "") or "").strip()
egress_item = self._refresh_egress_identity_scope(
f"transport:{cid}",
trigger_refresh=True,
silent=True,
)
egress_short = self._format_egress_identity_short(egress_item)
if status == "up":
color = "green"
elif status in ("degraded", "unknown"):
color = "orange"
else:
color = "gray"
parts = [f"{kind} ({cid})", f"status={status}"]
if iface:
parts.append(f"iface={iface}")
if table:
parts.append(f"table={table}")
if latency > 0:
parts.append(f"latency={latency}ms")
if last_error:
short_err = last_error if len(last_error) <= 180 else last_error[:177] + "..."
parts.append(f"error={short_err}")
self.lbl_transport_engine_meta.setText("Engine: " + " | ".join(parts))
self.lbl_transport_engine_meta.setStyleSheet(f"color: {color};")
protocol_txt = self._singbox_client_protocol_summary(selected)
route, dns, killswitch = self._effective_singbox_policy()
profile_title = str(getattr(selected, "name", "") or "").strip() or cid
self.lbl_transport_selected_engine.setText(
f"Selected profile: {profile_title} ({cid})"
)
self.lbl_transport_selected_engine.setStyleSheet("color: black;")
self.lbl_singbox_profile_name.setText(f"Profile: {profile_title} ({cid})")
self.lbl_singbox_profile_name.setStyleSheet("color: black;")
self.btn_transport_engine_toggle.blockSignals(True)
if status == "up":
self.btn_transport_engine_toggle.setChecked(True)
self.btn_transport_engine_toggle.setText("Connected")
self.btn_transport_engine_toggle.setStyleSheet("color: #1f6b2f;")
self.btn_transport_engine_toggle.setToolTip("Connected. Click to disconnect.")
elif status == "starting":
self.btn_transport_engine_toggle.setChecked(False)
self.btn_transport_engine_toggle.setText("Connecting...")
self.btn_transport_engine_toggle.setStyleSheet("color: orange;")
self.btn_transport_engine_toggle.setToolTip("Engine is starting. Please wait.")
else:
self.btn_transport_engine_toggle.setChecked(False)
self.btn_transport_engine_toggle.setText("Disconnected")
self.btn_transport_engine_toggle.setStyleSheet("color: gray;")
self.btn_transport_engine_toggle.setToolTip("Disconnected. Click to connect.")
self.btn_transport_engine_toggle.blockSignals(False)
self.lbl_singbox_metric_conn_value.setText(status.upper())
conn_sub = "No latency sample"
if last_error:
short = last_error if len(last_error) <= 72 else last_error[:69] + "..."
conn_sub = short
elif latency > 0:
conn_sub = f"Latency {latency}ms"
if egress_short:
conn_sub = f"{conn_sub} · {egress_short}"
self.lbl_singbox_metric_conn_sub.setText(conn_sub)
updated_at = str(getattr(selected, "updated_at", "") or "").strip()
stamp = last_check or updated_at
self.lbl_singbox_metric_profile_value.setText(profile_title)
if stamp:
self.lbl_singbox_metric_profile_sub.setText(f"{cid} · updated {stamp}")
else:
self.lbl_singbox_metric_profile_sub.setText(cid)
self.lbl_singbox_metric_proto_value.setText(protocol_txt)
tunnel_bits = []
if iface:
tunnel_bits.append(f"iface={iface}")
if table:
tunnel_bits.append(f"table={table}")
self.lbl_singbox_metric_proto_sub.setText(" | ".join(tunnel_bits) if tunnel_bits else "iface/table n/a")
self.lbl_singbox_metric_policy_value.setText(
f"{self._singbox_value_label('routing', route)} + {self._singbox_value_label('dns', dns)}"
)
self.lbl_singbox_metric_policy_sub.setText(
f"Kill-switch: {self._singbox_value_label('killswitch', killswitch)}"
)
def _sort_transport_clients(self, clients) -> list:
return sorted(
list(clients or []),
key=lambda x: (
str(getattr(x, "kind", "") or "").lower(),
str(getattr(x, "name", "") or getattr(x, "id", "")).lower(),
str(getattr(x, "id", "") or "").lower(),
),
)
def _refresh_transport_policy_clients_cache(self) -> None:
fallback = self._sort_transport_clients(self._transport_clients)
try:
clients = self.ctrl.transport_clients(
enabled_only=False,
kind="",
include_virtual=True,
)
self._transport_policy_clients = self._sort_transport_clients(clients)
except Exception:
self._transport_policy_clients = fallback
def _policy_clients_for_editor(self) -> list:
clients = list(getattr(self, "_transport_policy_clients", []) or [])
if clients:
return clients
return self._sort_transport_clients(self._transport_clients)
def _is_virtual_policy_client(self, client) -> bool:
cid = str(getattr(client, "id", "") or "").strip().lower()
kind = str(getattr(client, "kind", "") or "").strip().lower()
if cid == "adguardvpn":
return True
return kind in ("adguardvpn", "virtual")
def _policy_client_role(self, client) -> str:
return "virtual" if self._is_virtual_policy_client(client) else "transport"
def _policy_client_role_by_id(self, client_id: str) -> str:
cid = str(client_id or "").strip().lower()
if not cid:
return "transport"
for client in self._policy_clients_for_editor():
cur = str(getattr(client, "id", "") or "").strip().lower()
if cur == cid:
return self._policy_client_role(client)
if cid == "adguardvpn":
return "virtual"
return "transport"
def _policy_client_caption(self, client) -> str:
cid = str(getattr(client, "id", "") or "").strip()
if not cid:
return "-"
role = self._policy_client_role(client)
name = str(getattr(client, "name", "") or "").strip() or cid
base = cid if name == cid else f"{name} ({cid})"
return f"[{role}] {base}"
def refresh_transport_engines(self, *, silent: bool = True) -> None:
prev_id = self._selected_transport_engine_id()
try:
clients = self.ctrl.transport_clients(enabled_only=False, kind=self._transport_kind)
self._transport_api_supported = True
self._transport_clients = self._sort_transport_clients(clients)
status_by_id = {
str(getattr(c, "id", "") or "").strip(): str(getattr(c, "status", "") or "").strip().lower()
for c in self._transport_clients
if str(getattr(c, "id", "") or "").strip()
}
keep_ids = {
str(getattr(c, "id", "") or "").strip()
for c in self._transport_clients
if str(getattr(c, "id", "") or "").strip()
}
cleaned_live = {}
for cid, snap in (self._transport_health_live or {}).items():
if cid not in keep_ids:
continue
base_status = status_by_id.get(cid, "")
snap_status = str((snap or {}).get("status") or "").strip().lower()
# Drop stale optimistic/live "UP" when backend state is already non-UP.
if base_status and base_status != "up" and snap_status == "up":
continue
cleaned_live[cid] = snap
self._transport_health_live = cleaned_live
except ApiError as e:
code = int(getattr(e, "status_code", 0) or 0)
self._transport_clients = []
self._transport_policy_clients = []
self._transport_api_supported = False
self._transport_health_live = {}
self.cmb_transport_engine.blockSignals(True)
self.cmb_transport_engine.clear()
if code == 404:
self.cmb_transport_engine.addItem("Transport API unavailable", "")
else:
self.cmb_transport_engine.addItem("Transport engine load failed", "")
self.cmb_transport_engine.blockSignals(False)
self._render_singbox_profile_cards()
self._update_transport_engine_view()
self.refresh_transport_policy_locks(silent=True)
if not silent and code != 404:
QMessageBox.warning(self, "Transport engine error", str(e))
return
except Exception as e:
self._transport_clients = []
self._transport_policy_clients = []
self._transport_api_supported = False
self._transport_health_live = {}
self.cmb_transport_engine.blockSignals(True)
self.cmb_transport_engine.clear()
self.cmb_transport_engine.addItem("Transport engine load failed", "")
self.cmb_transport_engine.blockSignals(False)
self._render_singbox_profile_cards()
self._update_transport_engine_view()
self.refresh_transport_policy_locks(silent=True)
if not silent:
QMessageBox.warning(self, "Transport engine error", str(e))
return
self.cmb_transport_engine.blockSignals(True)
self.cmb_transport_engine.clear()
pick = -1
up_pick = -1
for i, c in enumerate(self._transport_clients):
cid = str(getattr(c, "id", "") or "").strip()
if not cid:
continue
kind = str(getattr(c, "kind", "") or "").strip().upper()
name = str(getattr(c, "name", "") or "").strip() or cid
status = str(getattr(c, "status", "") or "").strip().lower() or "unknown"
self.cmb_transport_engine.addItem(f"{kind} · {name} [{status}]", cid)
if prev_id and cid == prev_id:
pick = self.cmb_transport_engine.count() - 1
if up_pick < 0 and status == "up":
up_pick = self.cmb_transport_engine.count() - 1
if self.cmb_transport_engine.count() == 0:
self.cmb_transport_engine.addItem("No SingBox clients configured", "")
pick = 0
elif pick < 0:
pick = up_pick if up_pick >= 0 else 0
self.cmb_transport_engine.setCurrentIndex(max(0, pick))
self.cmb_transport_engine.blockSignals(False)
self._render_singbox_profile_cards()
self._sync_singbox_profile_card_selection(self._selected_transport_engine_id())
self._sync_selected_singbox_profile_link(silent=True)
self._load_singbox_editor_for_selected(silent=True)
self._update_transport_engine_view()
self.refresh_transport_policy_locks(silent=True)
def _build_switch_intents(
self,
intents,
client_id: str,
) -> tuple[list[TransportPolicyIntent], int, int]:
out: list[TransportPolicyIntent] = []
changed = 0
total = 0
for it in list(intents or []):
total += 1
selector_type = str(getattr(it, "selector_type", "") or "").strip().lower()
selector_value = str(getattr(it, "selector_value", "") or "").strip()
prev_client_id = str(getattr(it, "client_id", "") or "").strip()
if not selector_type or not selector_value:
continue
if prev_client_id != client_id:
changed += 1
try:
prio = int(getattr(it, "priority", 100) or 100)
except Exception:
prio = 100
mode = str(getattr(it, "mode", "strict") or "strict").strip().lower()
if mode not in ("strict", "fallback"):
mode = "strict"
out.append(
TransportPolicyIntent(
selector_type=selector_type,
selector_value=selector_value,
client_id=client_id,
priority=prio if prio > 0 else 100,
mode=mode,
)
)
return out, changed, total
def _apply_transport_switch_policy(self, client_id: str) -> tuple[bool, str]:
pol = self.ctrl.transport_policy()
next_intents, changed, total = self._build_switch_intents(pol.intents, client_id)
if total <= 0 or not next_intents:
return True, "policy intents are empty; routing switch skipped"
if changed <= 0:
return True, f"policy already points to {client_id}; switch skipped"
flow = self.ctrl.transport_flow_draft(next_intents, base_revision=int(pol.revision))
flow = self.ctrl.transport_flow_validate(flow, allow_warnings=True)
if flow.phase == "risky":
preview = []
for c in list(flow.conflicts or [])[:5]:
reason = str(getattr(c, "reason", "") or "").strip()
ctype = str(getattr(c, "type", "") or "").strip() or "conflict"
if reason:
preview.append(f"- {ctype}: {reason}")
else:
preview.append(f"- {ctype}")
extra = "\n".join(preview)
txt = (
f"Switch has blocking conflicts.\n\n"
f"blocks={flow.block_count}, warns={flow.warn_count}\n\n"
f"{extra}\n\n"
f"Force apply anyway?"
)
ans = QMessageBox.question(
self,
"Confirm risky switch",
txt,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans != QMessageBox.Yes:
return False, "connect/switch canceled by user"
flow = self.ctrl.transport_flow_confirm(flow)
flow = self.ctrl.transport_flow_apply(flow, force_override=True)
else:
flow = self.ctrl.transport_flow_apply(flow, force_override=False)
if flow.phase != "applied":
return False, f"policy apply failed: {flow.message or '-'} (code={flow.code or '-'})"
return True, f"policy applied revision={flow.applied_revision} apply_id={flow.apply_id or '-'}"
def on_transport_engine_selected(self, _index: int = 0) -> None:
self._sync_singbox_profile_card_selection(self._selected_transport_engine_id())
self._sync_selected_singbox_profile_link(silent=True)
self._load_singbox_editor_for_selected(silent=True)
self._update_transport_engine_view()
self._refresh_selected_transport_health_live(silent=True)
def on_transport_engine_refresh(self) -> None:
selected_id = self._selected_transport_engine_id()
try:
targets = [selected_id] if selected_id else None
self.ctrl.transport_health_refresh(client_ids=targets, force=True)
except Exception:
# Endpoint is best-effort; UI must keep working with older API builds.
pass
try:
scope = f"transport:{selected_id}" if selected_id else ""
if scope:
self.ctrl.egress_identity_refresh(scopes=[scope], force=True)
except Exception:
# Optional API on older backend builds.
pass
self.refresh_transport_engines(silent=False)
self._refresh_selected_transport_health_live(force=True, silent=True)
def on_transport_engine_toggle(self, _checked: bool = False) -> None:
selected = self._selected_transport_client()
if selected is None:
QMessageBox.warning(self, "Transport engine", "Select a transport engine first")
return
status = str(getattr(selected, "status", "") or "").strip().lower()
if status == "starting":
QMessageBox.information(self, "Transport engine", "Engine is still starting, wait a moment")
return
action: Literal["provision", "start", "stop", "restart"] = "stop" if status == "up" else "start"
self.on_transport_engine_action(action)
def on_transport_netns_toggle(self) -> None:
def work():
if not self._transport_api_supported:
raise RuntimeError("Transport API is unavailable")
clients = list(self._transport_clients or [])
if not clients:
raise RuntimeError("No SingBox engines configured")
all_enabled, _any_enabled = self._singbox_clients_netns_state()
target = not all_enabled
target_text = "ON" if target else "OFF"
ans = QMessageBox.question(
self,
"Debug netns",
(
f"Set netns={target_text} for all SingBox engines?\n\n"
"Applied now via backend orchestration.\n"
"Config+provision are executed in Go API,\n"
"running engines will be restarted."
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if ans != QMessageBox.Yes:
return
def log_netns_line(line: str) -> None:
msg = (line or "").strip()
if not msg:
return
self._append_transport_log(f"[netns] {msg}")
self.ctrl.log_gui(f"[netns] {msg}")
failures = apply_singbox_netns_toggle(
self.ctrl,
clients,
target,
log_netns_line,
)
self.refresh_transport_engines(silent=True)
self.refresh_status_tab()
if failures:
raise RuntimeError(
"netns toggle completed with errors:\n" + "\n".join(failures)
)
self.lbl_transport_engine_meta.setText(
f"Engine: debug netns set to {target_text} for all SingBox engines"
)
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
self._safe(work, title="Debug netns toggle error")
def _selected_multiif_engine_scope(self) -> str:
combo = getattr(self, "cmb_singbox_owner_engine_scope", None)
if combo is None:
return "all"
scope = str(combo.currentData() or "").strip().lower()
if scope not in ("all", "transport", "adguardvpn"):
return "all"
return scope
def on_singbox_owner_engine_scope_changed(self, _index: int = 0) -> None:
self._update_transport_engine_view()
self._safe(
lambda: self.refresh_transport_policy_locks(silent=True),
title="MultiIF engine filter error",
)
def _new_readonly_table_item(self, text: str, *, user_data: str = "") -> QTableWidgetItem:
item = QTableWidgetItem(str(text or ""))
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
if user_data:
item.setData(Qt.UserRole, str(user_data))
return item
def _selected_owner_lock_destinations(self) -> list[str]:
rows = sorted({idx.row() for idx in self.tbl_singbox_owner_locks.selectedIndexes()})
out: list[str] = []
seen: set[str] = set()
for row in rows:
it = self.tbl_singbox_owner_locks.item(row, 0)
if it is None:
continue
ip = str(it.text() or "").strip()
if not ip or ip in seen:
continue
seen.add(ip)
out.append(ip)
return out
def _parse_owner_lock_destination_filter(self) -> list[str]:
raw = str(self.ent_singbox_owner_lock_destination.text() or "").strip()
if not raw:
return []
buf = raw.replace(",", " ").replace(";", " ").replace("\n", " ")
out: list[str] = []
seen: set[str] = set()
for token in buf.split():
ip = token.strip()
if not ip or ip in seen:
continue
seen.add(ip)
out.append(ip)
return out
def on_singbox_policy_selector_type_changed(self, _index: int = 0) -> None:
selector_type = str(self.cmb_singbox_policy_selector_type.currentData() or "").strip().lower()
placeholder = "example.com"
if selector_type == "cidr":
placeholder = "1.2.3.0/24 or 1.2.3.4"
elif selector_type == "app_key":
placeholder = "steam"
elif selector_type == "cgroup":
placeholder = "user.slice/app.scope"
elif selector_type == "uid":
placeholder = "1000"
self.ent_singbox_policy_selector_value.setPlaceholderText(placeholder)
def _set_singbox_policy_state(self, text: str, color: str = "gray") -> None:
self.lbl_singbox_policy_state.setText(str(text or "").strip() or "Policy editor: —")
self.lbl_singbox_policy_state.setStyleSheet(f"color: {color};")
def _sync_singbox_policy_client_options(self) -> None:
prev = str(self.cmb_singbox_policy_client_id.currentData() or "").strip()
self.cmb_singbox_policy_client_id.blockSignals(True)
self.cmb_singbox_policy_client_id.clear()
seen = set()
for client in self._policy_clients_for_editor():
cid = str(getattr(client, "id", "") or "").strip()
if not cid or cid in seen:
continue
seen.add(cid)
self.cmb_singbox_policy_client_id.addItem(self._policy_client_caption(client), cid)
if self.cmb_singbox_policy_client_id.count() == 0:
self.cmb_singbox_policy_client_id.addItem("No clients", "")
idx = self.cmb_singbox_policy_client_id.findData(prev)
if idx < 0:
idx = 0
self.cmb_singbox_policy_client_id.setCurrentIndex(max(0, idx))
self.cmb_singbox_policy_client_id.blockSignals(False)
def _policy_client_label(self, client_id: str) -> str:
cid = str(client_id or "").strip()
if not cid:
return "-"
for client in self._policy_clients_for_editor():
cur = str(getattr(client, "id", "") or "").strip()
if cur != cid:
continue
return self._policy_client_caption(client)
return f"[{self._policy_client_role_by_id(cid)}] {cid}"
def _set_policy_client_selection(self, client_id: str) -> None:
cid = str(client_id or "").strip()
if not cid:
return
idx = self.cmb_singbox_policy_client_id.findData(cid)
if idx >= 0:
self.cmb_singbox_policy_client_id.setCurrentIndex(idx)
def _intent_parts(self, intent) -> tuple[str, str, str, str, int]:
if isinstance(intent, dict):
selector_type = str(intent.get("selector_type") or "").strip().lower()
selector_value = str(intent.get("selector_value") or "").strip()
client_id = str(intent.get("client_id") or "").strip()
mode = str(intent.get("mode") or "strict").strip().lower() or "strict"
try:
priority = int(intent.get("priority") or 100)
except Exception:
priority = 100
else:
selector_type = str(getattr(intent, "selector_type", "") or "").strip().lower()
selector_value = str(getattr(intent, "selector_value", "") or "").strip()
client_id = str(getattr(intent, "client_id", "") or "").strip()
mode = str(getattr(intent, "mode", "strict") or "strict").strip().lower() or "strict"
try:
priority = int(getattr(intent, "priority", 100) or 100)
except Exception:
priority = 100
if mode not in ("strict", "fallback"):
mode = "strict"
if priority <= 0:
priority = 100
return selector_type, selector_value, client_id, mode, priority
def _render_singbox_policy_table_rows(self, table, intents) -> None:
table.setRowCount(0)
for it in list(intents or []):
selector_type, selector_value, client_id, mode, priority = self._intent_parts(it)
row = table.rowCount()
table.insertRow(row)
table.setItem(row, 0, self._new_readonly_table_item(selector_type))
table.setItem(row, 1, self._new_readonly_table_item(selector_value))
table.setItem(
row,
2,
self._new_readonly_table_item(self._policy_client_label(client_id), user_data=client_id),
)
table.setItem(row, 3, self._new_readonly_table_item(mode))
table.setItem(row, 4, self._new_readonly_table_item(str(priority)))
table.resizeRowsToContents()
def _render_singbox_policy_table(self) -> None:
self._render_singbox_policy_table_rows(
self.tbl_singbox_policy_intents,
self._transport_policy_draft_intents,
)
self._render_singbox_policy_table_rows(
self.tbl_singbox_policy_applied,
self._transport_policy_applied_intents,
)
def _render_singbox_policy_conflicts(self, conflicts) -> None:
self.tbl_singbox_policy_conflicts.setRowCount(0)
for it in list(conflicts or []):
ctype = str(getattr(it, "type", "") or "").strip() or "-"
severity = str(getattr(it, "severity", "") or "").strip() or "-"
owners_list = list(getattr(it, "owners", []) or [])
owners = ", ".join(str(x).strip() for x in owners_list if str(x).strip()) or "-"
reason = str(getattr(it, "reason", "") or "").strip() or "-"
suggested = str(getattr(it, "suggested_resolution", "") or "").strip() or "-"
row = self.tbl_singbox_policy_conflicts.rowCount()
self.tbl_singbox_policy_conflicts.insertRow(row)
self.tbl_singbox_policy_conflicts.setItem(row, 0, self._new_readonly_table_item(ctype))
self.tbl_singbox_policy_conflicts.setItem(row, 1, self._new_readonly_table_item(severity.upper()))
self.tbl_singbox_policy_conflicts.setItem(row, 2, self._new_readonly_table_item(owners))
self.tbl_singbox_policy_conflicts.setItem(row, 3, self._new_readonly_table_item(reason))
self.tbl_singbox_policy_conflicts.setItem(row, 4, self._new_readonly_table_item(suggested))
self.tbl_singbox_policy_conflicts.resizeRowsToContents()
def refresh_transport_policy_editor(self, *, silent: bool = True, force: bool = False, policy=None) -> None:
self._sync_singbox_policy_client_options()
if not getattr(self, "_transport_api_supported", False):
self.tbl_singbox_policy_intents.setRowCount(0)
self.tbl_singbox_policy_applied.setRowCount(0)
self.tbl_singbox_policy_conflicts.setRowCount(0)
self._set_singbox_policy_state("Policy editor: API unavailable", "orange")
return
pol = policy
if pol is None:
try:
pol = self.ctrl.transport_policy()
except ApiError as e:
code = int(getattr(e, "status_code", 0) or 0)
self.tbl_singbox_policy_intents.setRowCount(0)
self.tbl_singbox_policy_applied.setRowCount(0)
self.tbl_singbox_policy_conflicts.setRowCount(0)
if code == 404:
self._set_singbox_policy_state("Policy editor: endpoint unavailable", "gray")
return
self._set_singbox_policy_state("Policy editor: refresh failed", "orange")
if not silent:
raise
return
backend_revision = int(getattr(pol, "revision", 0) or 0)
backend_intents = list(getattr(pol, "intents", []) or [])
self._transport_policy_applied_intents = list(backend_intents)
should_reload = bool(force or not self._transport_policy_dirty or self._transport_policy_base_revision <= 0)
if should_reload:
self._transport_policy_base_revision = backend_revision
self._transport_policy_draft_intents = list(backend_intents)
self._transport_policy_dirty = False
self._transport_policy_last_apply_id = ""
self._transport_policy_last_conflicts = []
self._render_singbox_policy_table()
self._render_singbox_policy_conflicts(self._transport_policy_last_conflicts)
draft_count = len(list(self._transport_policy_draft_intents or []))
applied_count = len(list(self._transport_policy_applied_intents or []))
backend_count = len(backend_intents)
if self._transport_policy_dirty:
self._set_singbox_policy_state(
f"Policy draft: dirty (draft={draft_count}, applied={applied_count}, base_rev={self._transport_policy_base_revision}, backend_rev={backend_revision}, backend_intents={backend_count})",
"#b58900",
)
else:
self._set_singbox_policy_state(
f"Policy draft: clean (rev={self._transport_policy_base_revision}, draft={draft_count}, applied={applied_count})",
"gray",
)
def on_singbox_policy_reload(self) -> None:
self._safe(
lambda: self.refresh_transport_policy_editor(silent=False, force=True),
title="Policy reload error",
)
def on_singbox_policy_use_template(self) -> None:
data = self.cmb_singbox_policy_template.currentData()
if not isinstance(data, dict):
QMessageBox.information(
self,
"Policy template",
"Select a template first.",
)
return
selector_type = str(data.get("selector_type") or "").strip().lower()
selector_value = str(data.get("selector_value") or "").strip()
mode = str(data.get("mode") or "strict").strip().lower()
try:
priority = int(data.get("priority") or 100)
except Exception:
priority = 100
idx = self.cmb_singbox_policy_selector_type.findData(selector_type)
if idx >= 0:
self.cmb_singbox_policy_selector_type.setCurrentIndex(idx)
self.ent_singbox_policy_selector_value.setText(selector_value)
idx_mode = self.cmb_singbox_policy_mode.findData(mode)
if idx_mode >= 0:
self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode)
self.spn_singbox_policy_priority.setValue(priority if priority > 0 else 100)
self._set_singbox_policy_state(
f"Template loaded: {selector_type}:{selector_value} ({mode}, prio={priority})",
"#1f6b2f",
)
def on_singbox_policy_add_demo_intent(self) -> None:
def work() -> None:
client_id = str(self.cmb_singbox_policy_client_id.currentData() or "").strip()
if not client_id:
for i in range(self.cmb_singbox_policy_client_id.count()):
cand = str(self.cmb_singbox_policy_client_id.itemData(i) or "").strip()
if cand:
client_id = cand
break
if not client_id:
QMessageBox.information(
self,
"Policy intent",
"No client is available yet. Create or select a connection first.",
)
return
used_values = {
str(it.selector_value or "").strip().lower()
for it in list(self._transport_policy_draft_intents or [])
if str(it.selector_type or "").strip().lower() == "domain"
}
n = 1
selector_value = "demo.invalid"
while selector_value.lower() in used_values:
n += 1
selector_value = f"demo-{n}.invalid"
idx = self.cmb_singbox_policy_selector_type.findData("domain")
if idx >= 0:
self.cmb_singbox_policy_selector_type.setCurrentIndex(idx)
self.ent_singbox_policy_selector_value.setText(selector_value)
self._set_policy_client_selection(client_id)
idx_mode = self.cmb_singbox_policy_mode.findData("strict")
if idx_mode >= 0:
self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode)
self.spn_singbox_policy_priority.setValue(100)
self.on_singbox_policy_add_intent()
self._set_singbox_policy_state(
f"Demo intent added: domain:{selector_value} -> {client_id}",
"#1f6b2f",
)
self._safe(work, title="Policy demo intent error")
def _selected_policy_intent_row(self) -> int:
rows = sorted({idx.row() for idx in self.tbl_singbox_policy_intents.selectedIndexes()})
if not rows:
return -1
row = rows[0]
if row < 0 or row >= len(self._transport_policy_draft_intents or []):
return -1
return row
def on_singbox_policy_load_selected_intent(self, *, _silent: bool = False) -> None:
row = self._selected_policy_intent_row()
if row < 0:
if not _silent:
QMessageBox.information(self, "Policy intent", "Select an intent row first.")
return
selector_type, selector_value, client_id, mode, priority = self._intent_parts(
self._transport_policy_draft_intents[row]
)
idx = self.cmb_singbox_policy_selector_type.findData(selector_type)
if idx >= 0:
self.cmb_singbox_policy_selector_type.setCurrentIndex(idx)
self.ent_singbox_policy_selector_value.setText(selector_value)
self._set_policy_client_selection(client_id)
idx_mode = self.cmb_singbox_policy_mode.findData(mode)
if idx_mode >= 0:
self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode)
self.spn_singbox_policy_priority.setValue(priority if priority > 0 else 100)
self._set_singbox_policy_state(
f"Intent loaded from draft row {row + 1}: {selector_type}:{selector_value} -> {client_id}",
"#1f6b2f",
)
def on_singbox_policy_intent_double_clicked(self, item: QTableWidgetItem | None) -> None:
if item is None:
return
row = int(item.row())
if row < 0:
return
self.tbl_singbox_policy_intents.selectRow(row)
self.on_singbox_policy_load_selected_intent(_silent=True)
def _build_policy_intent_from_form(self) -> tuple[TransportPolicyIntent, str, str, str, str, int]:
selector_type = str(self.cmb_singbox_policy_selector_type.currentData() or "").strip().lower()
selector_value = str(self.ent_singbox_policy_selector_value.text() or "").strip()
client_id = str(self.cmb_singbox_policy_client_id.currentData() or "").strip()
mode = str(self.cmb_singbox_policy_mode.currentData() or "strict").strip().lower()
priority = int(self.spn_singbox_policy_priority.value() or 100)
if not selector_type or not selector_value or not client_id:
raise ValueError("Fill selector type/value and client ID before saving intent.")
if selector_type == "cidr":
try:
if "/" in selector_value:
ipaddress.ip_network(selector_value, strict=False)
else:
ipaddress.ip_address(selector_value)
except Exception as e:
raise ValueError("CIDR/IP value looks invalid. Example: 1.2.3.0/24 or 1.2.3.4") from e
if selector_type == "uid" and not selector_value.isdigit():
raise ValueError("UID must be numeric (example: 1000).")
if selector_type == "domain" and selector_value.isdigit():
raise ValueError("Domain value looks invalid. Use host/domain (example.com).")
normalized_mode = mode if mode in ("strict", "fallback") else "strict"
normalized_priority = priority if priority > 0 else 100
intent = TransportPolicyIntent(
selector_type=selector_type,
selector_value=selector_value,
client_id=client_id,
priority=normalized_priority,
mode=normalized_mode,
)
return (
intent,
selector_type,
selector_value,
client_id,
normalized_mode,
normalized_priority,
)
def _policy_intent_exists(self, intent: TransportPolicyIntent, *, skip_index: int = -1) -> bool:
target = self._intent_parts(intent)
for i, existing in enumerate(list(self._transport_policy_draft_intents or [])):
if skip_index >= 0 and i == skip_index:
continue
if self._intent_parts(existing) == target:
return True
return False
def on_singbox_policy_add_intent(self) -> None:
def work() -> None:
try:
intent, selector_type, selector_value, client_id, mode, priority = self._build_policy_intent_from_form()
except ValueError as e:
QMessageBox.information(self, "Policy intent", str(e))
return
if self._policy_intent_exists(intent):
QMessageBox.information(
self,
"Policy intent",
"Same intent is already present in draft.",
)
return
self._transport_policy_draft_intents.append(intent)
self._transport_policy_dirty = True
self._render_singbox_policy_table()
row = len(self._transport_policy_draft_intents) - 1
if row >= 0 and row < self.tbl_singbox_policy_intents.rowCount():
self.tbl_singbox_policy_intents.selectRow(row)
self._set_singbox_policy_state(
f"Policy draft: dirty (intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})",
"#b58900",
)
self._append_transport_log(
f"[policy] draft add: {selector_type}:{selector_value} -> {client_id} ({mode}, prio={priority})"
)
self.ctrl.log_gui(
f"[transport-policy] draft add {selector_type}:{selector_value} -> {client_id} mode={mode} priority={priority}"
)
self._safe(work, title="Policy add error")
def on_singbox_policy_update_selected_intent(self) -> None:
def work() -> None:
row = self._selected_policy_intent_row()
if row < 0:
QMessageBox.information(self, "Policy intent", "Select an intent row first.")
return
try:
intent, selector_type, selector_value, client_id, mode, priority = self._build_policy_intent_from_form()
except ValueError as e:
QMessageBox.information(self, "Policy intent", str(e))
return
if self._policy_intent_exists(intent, skip_index=row):
QMessageBox.information(
self,
"Policy intent",
"Another row already has the same intent.",
)
return
self._transport_policy_draft_intents[row] = intent
self._transport_policy_dirty = True
self._render_singbox_policy_table()
if row < self.tbl_singbox_policy_intents.rowCount():
self.tbl_singbox_policy_intents.selectRow(row)
self._set_singbox_policy_state(
f"Policy draft: dirty (updated row={row + 1}, intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})",
"#b58900",
)
self._append_transport_log(
f"[policy] draft update row={row + 1}: {selector_type}:{selector_value} -> {client_id} ({mode}, prio={priority})"
)
self.ctrl.log_gui(
f"[transport-policy] draft update row={row + 1} {selector_type}:{selector_value} -> {client_id} mode={mode} priority={priority}"
)
self._safe(work, title="Policy update error")
def on_singbox_policy_remove_selected(self) -> None:
def work() -> None:
rows = sorted({idx.row() for idx in self.tbl_singbox_policy_intents.selectedIndexes()}, reverse=True)
if not rows:
QMessageBox.information(self, "Policy intent", "Select one or more intent rows.")
return
removed = 0
for row in rows:
if 0 <= row < len(self._transport_policy_draft_intents):
del self._transport_policy_draft_intents[row]
removed += 1
if removed <= 0:
return
self._transport_policy_dirty = True
self._render_singbox_policy_table()
self._set_singbox_policy_state(
f"Policy draft: dirty (removed={removed}, intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})",
"#b58900",
)
self._append_transport_log(f"[policy] draft remove: {removed} intent(s)")
self.ctrl.log_gui(f"[transport-policy] draft remove: {removed}")
self._safe(work, title="Policy remove error")
def _validate_policy_draft_flow(self):
flow = self.ctrl.transport_flow_draft(
list(self._transport_policy_draft_intents or []),
base_revision=int(self._transport_policy_base_revision or 0),
)
flow = self.ctrl.transport_flow_validate(flow, allow_warnings=True)
line = (
f"validate: phase={flow.phase} blocks={int(flow.block_count)} warns={int(flow.warn_count)} "
f"diff +{int(flow.diff_added)}/~{int(flow.diff_changed)}/-{int(flow.diff_removed)} "
f"code={flow.code or '-'}"
)
self._append_transport_log(f"[policy] {line}")
self.ctrl.log_gui(f"[transport-policy] {line}")
self._transport_policy_last_conflicts = list(flow.conflicts or [])
self._render_singbox_policy_conflicts(self._transport_policy_last_conflicts)
if flow.phase == "validated":
self._set_singbox_policy_state(
f"Policy validated: blocks=0 warns={int(flow.warn_count)} diff +{int(flow.diff_added)}/~{int(flow.diff_changed)}/-{int(flow.diff_removed)}",
"green",
)
elif flow.phase == "risky":
self._set_singbox_policy_state(
f"Policy has blocking conflicts: blocks={int(flow.block_count)} warns={int(flow.warn_count)}",
"#b58900",
)
else:
self._set_singbox_policy_state(
f"Policy validate failed: {flow.message or '-'} (code={flow.code or '-'})",
"orange",
)
return flow
def on_singbox_policy_validate(self) -> None:
def work() -> None:
flow = self._validate_policy_draft_flow()
if flow.phase == "risky":
preview = []
for c in list(flow.conflicts or [])[:8]:
reason = str(getattr(c, "reason", "") or "").strip()
ctype = str(getattr(c, "type", "") or "").strip() or "conflict"
if reason:
preview.append(f"- {ctype}: {reason}")
else:
preview.append(f"- {ctype}")
if preview:
QMessageBox.information(
self,
"Policy conflicts",
"Blocking conflicts found:\n\n" + "\n".join(preview),
)
self._safe(work, title="Policy validate error")
def on_singbox_policy_apply(self) -> None:
def work() -> None:
flow = self._validate_policy_draft_flow()
if flow.phase == "risky":
preview = []
for c in list(flow.conflicts or [])[:8]:
reason = str(getattr(c, "reason", "") or "").strip()
ctype = str(getattr(c, "type", "") or "").strip() or "conflict"
if reason:
preview.append(f"- {ctype}: {reason}")
else:
preview.append(f"- {ctype}")
text = (
f"Policy has blocking conflicts.\n\n"
f"blocks={int(flow.block_count)}, warns={int(flow.warn_count)}\n\n"
f"{chr(10).join(preview)}\n\n"
"Force apply anyway?"
)
ans = QMessageBox.question(
self,
"Confirm risky policy apply",
text,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans != QMessageBox.Yes:
self._append_transport_log("[policy] apply canceled by user")
self.ctrl.log_gui("[transport-policy] apply canceled by user")
return
flow = self.ctrl.transport_flow_confirm(flow)
flow = self.ctrl.transport_flow_apply(flow, force_override=True)
else:
flow = self.ctrl.transport_flow_apply(flow, force_override=False)
if flow.phase != "applied":
raise RuntimeError(f"{flow.message or 'policy apply failed'} (code={flow.code or '-'})")
self._transport_policy_dirty = False
self._transport_policy_base_revision = int(flow.applied_revision or flow.current_revision or self._transport_policy_base_revision)
self._transport_policy_last_apply_id = str(flow.apply_id or "").strip()
self._append_transport_log(
f"[policy] applied: revision={int(flow.applied_revision)} apply_id={flow.apply_id or '-'}"
)
self.ctrl.log_gui(
f"[transport-policy] applied revision={int(flow.applied_revision)} apply_id={flow.apply_id or '-'}"
)
self.refresh_transport_policy_locks(silent=True)
self.refresh_transport_engines(silent=True)
self.refresh_status_tab()
self._safe(work, title="Policy apply error")
def on_singbox_policy_rollback_explicit(self) -> None:
def work() -> None:
res = self.ctrl.transport_policy_rollback_action()
line = (res.pretty_text or "").strip() or "policy rollback"
self._append_transport_log(f"[policy] {line}")
self.ctrl.log_gui(f"[transport-policy] {line}")
if not res.ok:
raise RuntimeError(line)
self._transport_policy_dirty = False
self._transport_policy_last_conflicts = []
self.refresh_transport_policy_editor(silent=True, force=True)
self.refresh_transport_policy_locks(silent=True)
self.refresh_transport_engines(silent=True)
self.refresh_status_tab()
self._safe(work, title="Policy rollback error")
def refresh_transport_policy_locks(self, *, silent: bool = True) -> None:
scope = self._selected_multiif_engine_scope()
show_transport_rows = scope in ("all", "transport")
show_adguard_rows = scope in ("all", "adguardvpn")
if not getattr(self, "_transport_api_supported", False):
self._transport_policy_clients = []
self.tbl_singbox_interfaces.setRowCount(0)
self.tbl_singbox_policy_intents.setRowCount(0)
self.tbl_singbox_policy_applied.setRowCount(0)
self.tbl_singbox_policy_conflicts.setRowCount(0)
self.tbl_singbox_ownership.setRowCount(0)
self.tbl_singbox_owner_locks.setRowCount(0)
self.lbl_singbox_owner_locks_summary.setText("Interfaces/Ownership: API unavailable")
self.lbl_singbox_owner_locks_summary.setStyleSheet("color: orange;")
self._set_singbox_policy_state("Policy editor: API unavailable", "orange")
return
ifaces = SimpleNamespace(count=0, items=[])
pol = SimpleNamespace(revision=0, intents=[])
owners = SimpleNamespace(count=0, lock_count=0, plan_digest="", items=[])
locks = SimpleNamespace(count=0, policy_revision=0, items=[])
try:
ifaces = self.ctrl.transport_interfaces()
pol = self.ctrl.transport_policy()
owners = self.ctrl.transport_ownership()
locks = self.ctrl.transport_owner_locks()
except ApiError as e:
self.tbl_singbox_interfaces.setRowCount(0)
self.tbl_singbox_policy_intents.setRowCount(0)
self.tbl_singbox_policy_applied.setRowCount(0)
self.tbl_singbox_policy_conflicts.setRowCount(0)
self.tbl_singbox_ownership.setRowCount(0)
self.tbl_singbox_owner_locks.setRowCount(0)
code = int(getattr(e, "status_code", 0) or 0)
if code == 404:
self.lbl_singbox_owner_locks_summary.setText(
"Interfaces/Ownership: endpoint is unavailable on current backend"
)
self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;")
self._set_singbox_policy_state("Policy editor: endpoint unavailable", "gray")
return
self.lbl_singbox_owner_locks_summary.setText("Interfaces/Ownership refresh failed")
self.lbl_singbox_owner_locks_summary.setStyleSheet("color: orange;")
self._set_singbox_policy_state("Policy editor: refresh failed", "orange")
if not silent:
raise
return
self._refresh_transport_policy_clients_cache()
self.refresh_transport_policy_editor(silent=True, force=False, policy=pol)
scope_title = {
"all": "all",
"transport": "transport",
"adguardvpn": "adguardvpn",
}.get(scope, "all")
self.lbl_singbox_interfaces_hint.setText(f"Interfaces (read-only, filter={scope_title}, labels=[transport]/[virtual])")
self.tbl_singbox_interfaces.setRowCount(0)
adguard_iface = None
for rec in list(getattr(ifaces, "items", []) or []):
rec_id = str(getattr(rec, "id", "") or "").strip().lower()
is_adguard = rec_id == "adguardvpn"
if is_adguard:
adguard_iface = rec
if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows):
continue
row = self.tbl_singbox_interfaces.rowCount()
self.tbl_singbox_interfaces.insertRow(row)
clients_txt = f"{int(rec.up_count or 0)}/{int(rec.client_count or 0)}"
iface_role = "virtual" if is_adguard else "transport"
mode_text = (rec.mode or "-").upper()
if is_adguard:
mode_text = f"{mode_text} | VIRTUAL"
self.tbl_singbox_interfaces.setItem(row, 0, self._new_readonly_table_item(f"[{iface_role}] {rec.id}", user_data=rec.id))
self.tbl_singbox_interfaces.setItem(row, 1, self._new_readonly_table_item(mode_text))
self.tbl_singbox_interfaces.setItem(row, 2, self._new_readonly_table_item(rec.runtime_iface or "-"))
self.tbl_singbox_interfaces.setItem(row, 3, self._new_readonly_table_item(rec.netns_name or "-"))
self.tbl_singbox_interfaces.setItem(row, 4, self._new_readonly_table_item(rec.routing_table or "-"))
self.tbl_singbox_interfaces.setItem(row, 5, self._new_readonly_table_item(clients_txt))
self.tbl_singbox_interfaces.setItem(row, 6, self._new_readonly_table_item(rec.updated_at or "-"))
self.tbl_singbox_ownership.setRowCount(0)
for rec in list(getattr(owners, "items", []) or []):
owner_scope_raw = str(getattr(rec, "owner_scope", "") or "").strip().lower()
owner_id = str(getattr(rec, "client_id", "") or "").strip().lower()
is_adguard = owner_scope_raw == "adguardvpn" or owner_id == "adguardvpn"
if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows):
continue
row = self.tbl_singbox_ownership.rowCount()
self.tbl_singbox_ownership.insertRow(row)
selector = f"{rec.selector_type}:{rec.selector_value}"
owner = rec.client_id
if rec.client_kind:
owner = f"{owner} ({rec.client_kind})"
owner_role = "virtual" if is_adguard else "transport"
owner = f"[{owner_role}] {owner}"
owner_scope = str(getattr(rec, "owner_scope", "") or "").strip() or "-"
iface_table = rec.iface_id or "-"
if rec.routing_table:
iface_table = f"{iface_table} · {rec.routing_table}"
status = rec.owner_status or "unknown"
lock_txt = "active" if rec.lock_active else "free"
self.tbl_singbox_ownership.setItem(row, 0, self._new_readonly_table_item(selector, user_data=rec.key))
self.tbl_singbox_ownership.setItem(row, 1, self._new_readonly_table_item(owner, user_data=rec.client_id))
self.tbl_singbox_ownership.setItem(row, 2, self._new_readonly_table_item(owner_scope))
self.tbl_singbox_ownership.setItem(row, 3, self._new_readonly_table_item(iface_table))
self.tbl_singbox_ownership.setItem(row, 4, self._new_readonly_table_item(status.upper()))
self.tbl_singbox_ownership.setItem(row, 5, self._new_readonly_table_item(lock_txt.upper()))
self.tbl_singbox_owner_locks.setRowCount(0)
for rec in list(getattr(locks, "items", []) or []):
owner_id = str(getattr(rec, "client_id", "") or "").strip().lower()
is_adguard = owner_id == "adguardvpn"
if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows):
continue
row = self.tbl_singbox_owner_locks.rowCount()
self.tbl_singbox_owner_locks.insertRow(row)
mark_proto = rec.mark_hex or "-"
if rec.proto:
mark_proto = f"{mark_proto} / {rec.proto}"
self.tbl_singbox_owner_locks.setItem(
row,
0,
self._new_readonly_table_item(rec.destination_ip, user_data=rec.destination_ip),
)
lock_role = "virtual" if is_adguard else "transport"
self.tbl_singbox_owner_locks.setItem(row, 1, self._new_readonly_table_item(f"[{lock_role}] {rec.client_id}", user_data=rec.client_id))
kind_text = rec.client_kind or ("adguardvpn" if is_adguard else "-")
self.tbl_singbox_owner_locks.setItem(row, 2, self._new_readonly_table_item(f"[{lock_role}] {kind_text}"))
self.tbl_singbox_owner_locks.setItem(row, 3, self._new_readonly_table_item(rec.iface_id or "-"))
self.tbl_singbox_owner_locks.setItem(row, 4, self._new_readonly_table_item(mark_proto))
self.tbl_singbox_owner_locks.setItem(row, 5, self._new_readonly_table_item(rec.updated_at or "-"))
self.tbl_singbox_interfaces.resizeRowsToContents()
self.tbl_singbox_ownership.resizeRowsToContents()
self.tbl_singbox_owner_locks.resizeRowsToContents()
filter_label = {
"all": "All",
"transport": "Transport",
"adguardvpn": "AdGuard VPN",
}.get(scope, "All")
parts = [
f"Engine filter: {filter_label}",
f"Interfaces: {self.tbl_singbox_interfaces.rowCount()}",
]
plan_digest = str(getattr(owners, "plan_digest", "") or "").strip()
plan_digest_short = plan_digest[:12] if plan_digest else "-"
parts.append(
"Policy: "
f"rev={int(getattr(pol, 'revision', 0) or 0)} intents={len(list(getattr(pol, 'intents', []) or []))}"
f" draft={len(list(self._transport_policy_draft_intents or []))}"
f" applied={len(list(self._transport_policy_applied_intents or []))}"
)
policy_clients = self._policy_clients_for_editor()
seen_target_ids = set()
virtual_targets = 0
transport_targets = 0
for client in policy_clients:
cid = str(getattr(client, "id", "") or "").strip().lower()
if not cid or cid in seen_target_ids:
continue
seen_target_ids.add(cid)
if self._is_virtual_policy_client(client):
virtual_targets += 1
else:
transport_targets += 1
parts.append(f"Targets: transport={transport_targets} virtual={virtual_targets}")
parts.append(
"Ownership: "
f"{int(getattr(owners, 'count', 0) or 0)} selectors "
f"(lock_active={int(getattr(owners, 'lock_count', 0) or 0)})"
f" · digest={plan_digest_short}"
)
parts.append(
"Locks: "
f"{int(getattr(locks, 'count', 0) or 0)}"
f" · revision={int(getattr(locks, 'policy_revision', 0) or 0)}"
)
if show_adguard_rows:
if adguard_iface is None:
parts.append("AdGuard: unavailable")
else:
adg_status = "UP" if int(getattr(adguard_iface, "up_count", 0) or 0) > 0 else "DOWN"
adg_iface = str(getattr(adguard_iface, "runtime_iface", "") or "").strip() or "-"
adg_table = str(getattr(adguard_iface, "routing_table", "") or "").strip() or "-"
parts.append(f"AdGuard: {adg_status} iface={adg_iface} table={adg_table}")
self.lbl_singbox_owner_locks_summary.setText(" | ".join(parts))
self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;")
if int(getattr(pol, "revision", 0) or 0) <= 0 and len(list(getattr(pol, "intents", []) or [])) == 0:
self._set_singbox_policy_state("Policy editor: no policy yet. Add intent -> Validate -> Apply.", "#1f6b2f")
def on_singbox_owner_locks_refresh(self) -> None:
self._safe(
lambda: self.refresh_transport_policy_locks(silent=False),
title="Ownership locks refresh error",
)
def on_singbox_owner_locks_clear(self) -> None:
def work() -> None:
if not self._transport_api_supported:
raise RuntimeError("Transport API is unavailable")
client_id = str(self.ent_singbox_owner_lock_client.text() or "").strip()
destination_ips = self._parse_owner_lock_destination_filter()
if not destination_ips:
destination_ips = self._selected_owner_lock_destinations()
if not client_id and not destination_ips:
msg = (
"Empty filter: set client_id or destination IP,\n"
"or select destination rows in locks table."
)
self._append_transport_log("[owner-lock] clear skipped: empty filter")
self.ctrl.log_gui("[owner-lock] clear skipped: empty filter")
QMessageBox.information(self, "Owner locks", msg)
return
base_revision = 0
try:
snap = self.ctrl.transport_owner_locks()
base_revision = int(getattr(snap, "policy_revision", 0) or 0)
except Exception:
pass
probe = self.ctrl.transport_owner_locks_clear(
base_revision=base_revision,
client_id=client_id,
destination_ips=destination_ips,
confirm_token="",
)
self._append_transport_log(
f"[owner-lock] clear probe: {probe.message or '-'} (code={probe.code or '-'})"
)
self.ctrl.log_gui(
f"[owner-lock] clear probe: code={probe.code or '-'} match={int(probe.match_count or 0)}"
)
if probe.ok and not probe.confirm_required:
self.refresh_transport_policy_locks(silent=True)
self.lbl_transport_engine_meta.setText(
f"Engine: owner locks cleared ({int(probe.cleared_count or 0)})"
)
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
return
if not probe.confirm_required:
raise RuntimeError(
f"{probe.message or 'owner-lock clear failed'} (code={probe.code or '-'})"
)
preview_lines = []
for it in list(probe.items or [])[:12]:
preview_lines.append(f"- {it.destination_ip} -> {it.client_id}")
if int(probe.match_count or 0) > len(preview_lines):
preview_lines.append(f"... +{int(probe.match_count or 0) - len(preview_lines)} more")
preview = "\n".join(preview_lines) if preview_lines else "No preview rows"
text = (
f"Matched locks: {int(probe.match_count or 0)}\n"
f"Current revision: {int(probe.base_revision or base_revision)}\n\n"
f"{preview}\n\n"
"Clear matched lock records?"
)
ans = QMessageBox.question(
self,
"Confirm owner-lock clear",
text,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans != QMessageBox.Yes:
self._append_transport_log("[owner-lock] clear canceled by user")
self.ctrl.log_gui("[owner-lock] clear canceled by user")
return
confirmed = self.ctrl.transport_owner_locks_clear(
base_revision=int(probe.base_revision or base_revision),
client_id=client_id,
destination_ips=destination_ips,
confirm_token=str(probe.confirm_token or "").strip(),
)
line = (
f"{confirmed.message or '-'} "
f"(code={confirmed.code or '-'}, cleared={int(confirmed.cleared_count or 0)})"
)
self._append_transport_log(f"[owner-lock] clear apply: {line}")
self.ctrl.log_gui(f"[owner-lock] clear apply: {line}")
if not confirmed.ok:
raise RuntimeError(line)
self.refresh_transport_policy_locks(silent=True)
self.lbl_transport_engine_meta.setText(
f"Engine: owner locks cleared ({int(confirmed.cleared_count or 0)})"
)
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
self._safe(work, title="Owner locks clear error")