zapret-kvn/xray_fluent/diagnostics/export.py
loop-uh e26419216a
Some checks failed
Windows project source guards / test (push) Failing after 1m12s
Программа «через VPN» — кроме российских сайтов; диагностика видит решения маршрута
Пользователь 0.8.36: VK и Яндекс перестали открываться, пока не добавил
прямые правила. В диагностике через туннель шли 29–39 % соединений к
российским IP (geoip-ru), но по ней нельзя было понять, каким правилом:
сайдкар видит только IP, а конфиг — уже исправленный. Шаблон и данные
маршрутизации в 0.8.30…0.8.36 не менялись (srs побайтно равны); причина —
правило пользователя, вставшее выше стокового «РФ напрямую».

- «Простое правило»: программа «Через VPN» по умолчанию «Кроме российских
  сайтов» — native logical and с {rule_set geosite-category-ru/geoip-ru (если
  есть в документе), domain_suffix ru/xn--p1ai/su, invert}; российское
  назначение идёт дальше по списку. Без галочки — предупреждение, что VK,
  Яндекс, банки уйдут в туннель. Семантика проверена ядром той же версии:
  `domain_suffix && !(domain_suffix=[ru xn--p1ai su]) => route(...)`.
- diagnostics/route_decisions: сводка решений маршрута по последним четырём
  сессиям (процесс, сайт из двух меток или IP, правило ядра «… => …»/final,
  маршрут, число соединений; отдельно российские сайты в туннеле) из того же
  опроса Clash API /connections, что метрики; журнал живёт у контроллера и
  переживает переподключение.
- runtime_redacted: route_history — правила route последних восьми запусков
  sing-box.
- Тесты: форма правила, explain_route (российские сайты мимо правила к
  стоковому direct), страница (галочка, предупреждение, «Напрямую»), сводка и
  история; docs/sing-box/routing-gui.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:05:27 +03:00

157 lines
5.2 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from dataclasses import asdict
import json
from pathlib import Path
import platform
import re
import zipfile
from ..profiles.models import AppState
from ..constants import APP_VERSION
from .runtime_logging import redact_runtime_log, register_server_nodes
REDACT_KEYS = {
"id",
"password",
"pass",
"token",
"publickey",
"privatekey",
"private_key",
"secretkey",
"secret_key",
"pre_shared_key",
"presharedkey",
"shortid",
"sid",
"uuid",
"url",
"pending_url",
"web_page_url",
"support_url",
"link",
"auth",
"auth_str",
"username",
"hwid",
"subscription_device_id",
"uri",
"raw_uri",
"public_key",
"header_protection_key",
"client_key",
"key",
"encryption",
"pin_sha256",
"certificate_sha256",
"certificate_public_key_sha256",
"password_hash",
"salt",
"authorization",
"proxy_authorization",
"cookie",
"set_cookie",
"obfs_password",
}
_NORMALIZED_REDACT_KEYS = {key.replace("_", "").replace("-", "") for key in REDACT_KEYS}
_URL_RE = re.compile(r"https?://[^\s'\"<>]+", re.IGNORECASE)
def _redact(value):
if isinstance(value, dict):
redacted = {}
for key, item in value.items():
if str(key).casefold().replace("_", "").replace("-", "") in _NORMALIZED_REDACT_KEYS:
redacted[key] = "***"
else:
redacted[key] = _redact(item)
return redacted
if isinstance(value, (list, tuple)):
return [_redact(item) for item in value]
if isinstance(value, str):
return _redact_log_line(value)
return value
def _redact_log_line(line: str) -> str:
return _URL_RE.sub("<URL скрыт>", redact_runtime_log(str(line)))
def capture_runtime_config(executable: Path, config: dict) -> dict:
"""Snapshot the JSON written by a manager, retaining no transport secrets."""
return {
"captured_at": datetime.now(timezone.utc).isoformat(),
"executable": str(executable),
"config": _redact(config),
}
def route_history_entry(snapshot: dict | None) -> dict | None:
"""Правила route из снимка конфига (уже без секретов) — запись истории."""
if not isinstance(snapshot, dict):
return None
route = (snapshot.get("config") or {}).get("route")
if not isinstance(route, dict):
return None
return {
"captured_at": snapshot.get("captured_at"),
"route_rules": route.get("rules") or [],
"route_final": route.get("final"),
}
def collect_runtime_diagnostics(controller) -> dict:
"""Use manager-owned snapshots, never reconstruct a supposed active config."""
contexts = getattr(controller, "_core_log_contexts", {})
components = {}
for component, attribute in (("sing-box", "singbox"), ("xray", "xray"), ("hysteria", "hysteria"), ("amnezia", "amnezia")):
manager = getattr(controller, attribute, None)
context = contexts.get(component)
components[component] = {
"running": bool(manager and manager.is_running),
"log_context": asdict(context) if context is not None else None,
"last_written_config": getattr(manager, "diagnostic_config", None),
"transport_stats": getattr(manager, "stats", None) if component in {"amnezia", "hysteria"} else None,
"route_history": list(getattr(manager, "diagnostic_route_history", None) or ()),
}
route_decisions = getattr(controller, "_route_decisions", None)
return _redact({
"schema": 1,
"connected": bool(controller.connected),
"components": components,
"route_decisions": route_decisions.snapshot() if route_decisions is not None else None,
})
def export_diagnostics(zip_path: Path, state: AppState, logs: list[str], *, runtime_errors=(), runtime=None) -> Path:
zip_path.parent.mkdir(parents=True, exist_ok=True)
# Буфер логов и журнал ошибок маскируются заново текущим реестром:
# адрес каждого узла в архиве — «<сервер node_ref>».
register_server_nodes(state.nodes)
safe_state = _redact(state.to_dict())
meta = {
"app_version": APP_VERSION,
"timestamp": datetime.now(timezone.utc).isoformat(),
"platform": platform.platform(),
"python": platform.python_version(),
}
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("state_redacted.json", json.dumps(safe_state, ensure_ascii=True, indent=2))
archive.writestr("meta.json", json.dumps(meta, ensure_ascii=True, indent=2))
archive.writestr("recent_logs.txt", "\n".join(_redact_log_line(line) for line in logs[-2000:]))
archive.writestr("runtime_errors.json", json.dumps(
_redact([asdict(record) for record in runtime_errors]), ensure_ascii=False, indent=2,
))
archive.writestr("runtime_redacted.json", json.dumps(
_redact(runtime) if runtime is not None else {"schema": 1, "components": {}, "available": False},
ensure_ascii=False, indent=2,
))
return zip_path