1458 lines
68 KiB
Python
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")
|