diff --git a/docker-compose.yml b/docker-compose.yml index d97686b..a139b5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,12 @@ GHARD_SHARE_INTERVAL: ${GHARD_SHARE_INTERVAL:-60} GHARD_HEALTH_INTERVAL: ${GHARD_HEALTH_INTERVAL:-30} GHARD_HEALTH_TIMEOUT: ${GHARD_HEALTH_TIMEOUT:-5} + # внешняя доступность сервисов: ожидаемый публичный IP и период проверки + GHARD_PUBLIC_IP: ${GHARD_PUBLIC_IP:-} + GHARD_PUBLIC_RESOLVER: ${GHARD_PUBLIC_RESOLVER:-1.1.1.1} + GHARD_EXTERNAL_INTERVAL: ${GHARD_EXTERNAL_INTERVAL:-60} + GHARD_EXTERNAL_TIMEOUT: ${GHARD_EXTERNAL_TIMEOUT:-5} + GHARD_EXTERNAL_ALERT_AFTER: ${GHARD_EXTERNAL_ALERT_AFTER:-300} GHARD_SYNAPSE_URL: ${GHARD_SYNAPSE_URL:-} GHARD_SYNAPSE_API_KEY: ${GHARD_SYNAPSE_API_KEY:-} GHARD_SYNAPSE_DEFAULT_SOURCE: ${GHARD_SYNAPSE_DEFAULT_SOURCE:-hard-panel} diff --git a/panel/backend/.env.example b/panel/backend/.env.example index 2fc98ac..a2b3a19 100644 --- a/panel/backend/.env.example +++ b/panel/backend/.env.example @@ -19,6 +19,19 @@ # таймаут одного health-запроса, сек GHARD_HEALTH_TIMEOUT=5 +# --- внешняя доступность сервисов (публичный путь) -------------------------- +# Ожидаемый публичный IP площадки с сервисами (например 51.83.131.93). +# Пусто = проверяем публичный путь без сверки DNS с ожидаемым адресом. +# GHARD_PUBLIC_IP= +# Публичный резолвер для сверки DNS в обход локального split-horizon +GHARD_PUBLIC_RESOLVER=1.1.1.1 +# период внешней проверки, сек +GHARD_EXTERNAL_INTERVAL=60 +# бюджет всей пробы (DNS + TCP + TLS + строка статуса), сек +GHARD_EXTERNAL_TIMEOUT=5 +# столько секунд непрерывного отказа — и только тогда событие в Synapse +GHARD_EXTERNAL_ALERT_AFTER=300 + # --- gnexus-auth (OAuth PKCE) ------------------------------------------------ # Задай GHARD_AUTH_CLIENT_ID — и веб-интерфейс закроется входом через # gnexus-auth (клиент регистрируется в админке gnexus-auth: /admin/clients). diff --git a/panel/backend/app/api/services.py b/panel/backend/app/api/services.py index 0ff74ae..ea1b44e 100644 --- a/panel/backend/app/api/services.py +++ b/panel/backend/app/api/services.py @@ -9,12 +9,14 @@ import json from datetime import datetime, timezone +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.config import get_settings from app.db import get_db +from app.external_probe import probe_service_once as probe_external_once from app.security import require_admin from app.service_meta import refresh_meta_now from app.services_probe import probe_service_once @@ -26,14 +28,47 @@ return datetime.now(timezone.utc).isoformat() +def _public_url(value: str) -> str: + """Публичный URL: только https, без логина/фрагмента, порт 443 (или пусто).""" + value = value.strip() + if not value: + return "" + parsed = urlparse(value) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("public_url: нужен https:// с доменом") + if parsed.username or parsed.password: + raise ValueError("public_url: логин/пароль в URL не нужны") + if parsed.fragment: + raise ValueError("public_url: фрагмент (#...) на сервер не уходит") + try: + port = parsed.port + except ValueError as exc: + raise ValueError("public_url: битый порт") from exc + if port not in (None, 443): + raise ValueError("public_url: проверяется публичный вход :443") + return value + + class ServiceCreate(BaseModel): name: str = Field(min_length=1, max_length=100) url: str = Field(min_length=1, max_length=500) + public_url: str = Field(default="", max_length=500) + + @field_validator("public_url") + @classmethod + def _check_public_url(cls, value: str) -> str: + return _public_url(value) class ServiceUpdate(BaseModel): name: str | None = None url: str | None = None + public_url: str | None = Field(default=None, max_length=500) + + @field_validator("public_url") + @classmethod + def _check_public_url(cls, value: str | None) -> str | None: + return None if value is None else _public_url(value) @router.get("/services") @@ -42,31 +77,62 @@ db = get_db() cursor = await db.execute( """SELECT sv.*, p.ts AS p_ts, p.state AS p_state, p.code AS p_code, - p.latency_ms AS p_latency, p.message AS p_message, p.report AS p_report + p.latency_ms AS p_latency, p.message AS p_message, p.report AS p_report, + e.ts AS e_ts, e.state AS e_state, e.code AS e_code, + e.latency_ms AS e_latency, e.cert_days AS e_cert_days, + e.dns_ips AS e_dns_ips, e.message AS e_message FROM services sv LEFT JOIN service_samples p ON p.id = ( SELECT MAX(id) FROM service_samples WHERE service_id = sv.id ) + LEFT JOIN service_external_samples e ON e.id = ( + SELECT MAX(id) FROM service_external_samples WHERE service_id = sv.id + ) ORDER BY sv.name""" ) rows = await cursor.fetchall() - stale_after = max(get_settings().health_interval * 3, 180) + settings = get_settings() + stale_after = max(settings.health_interval * 3, 180) + external_stale_after = max(settings.external_interval * 3, 180) + now = datetime.now(timezone.utc) result = [] for row in rows: status = "pending" if row["p_ts"] is not None: - age = (datetime.now(timezone.utc) - datetime.fromisoformat(row["p_ts"])).total_seconds() + age = (now - datetime.fromisoformat(row["p_ts"])).total_seconds() if age <= stale_after: status = row["p_state"] # up | degraded | down try: report = json.loads(row["p_report"]) if row["p_report"] else None except ValueError: report = None + external = None + if row["public_url"]: + external_status = "pending" + if row["e_ts"] is not None: + age = (now - datetime.fromisoformat(row["e_ts"])).total_seconds() + if age <= external_stale_after: + external_status = row["e_state"] + fresh = external_status != "pending" + try: + dns_ips = json.loads(row["e_dns_ips"]) if row["e_dns_ips"] else [] + except ValueError: + dns_ips = [] + external = { + "status": external_status, + "checked_at": row["e_ts"] if fresh else None, + "latency_ms": row["e_latency"] if fresh else None, + "code": row["e_code"] if fresh else None, + "dns_ips": dns_ips if fresh else [], + "cert_days": row["e_cert_days"] if fresh else None, + "message": row["e_message"] if fresh else "", + } result.append( { "id": row["id"], "name": row["name"], "url": row["url"], + "public_url": row["public_url"], "created_at": row["created_at"], "status": status, "last_sample": row["p_ts"], @@ -74,6 +140,7 @@ "code": row["p_code"] if status != "pending" else None, "message": row["p_message"] if status != "pending" else "", "report": report, + "external": external, "site_title": row["site_title"], "icon": row["icon"], } @@ -85,27 +152,44 @@ async def create_service(body: ServiceCreate) -> dict: db = get_db() cursor = await db.execute( - "INSERT INTO services (name, url, created_at) VALUES (?, ?, ?)", - (body.name, body.url, _now_iso()), + "INSERT INTO services (name, url, public_url, created_at) VALUES (?, ?, ?, ?)", + (body.name, body.url, body.public_url, _now_iso()), ) + service_id = cursor.lastrowid await db.commit() # первая проба и мета (title/favicon) сразу — не ждём ближайшей волны пробера try: - await probe_service_once(cursor.lastrowid, body.url) + await probe_service_once(service_id, body.url) except Exception as exc: # замер доберёт фоновый цикл print(f"services probe error (on create): {exc}", flush=True) try: - await refresh_meta_now(cursor.lastrowid, body.url) + await refresh_meta_now(service_id, body.url) except Exception as exc: # мета доберёт фоновый цикл print(f"service meta error (on create): {exc}", flush=True) - return {"id": cursor.lastrowid, "name": body.name, "url": body.url} + if body.public_url: # внешняя проверка — тоже сразу + try: + await probe_external_once(service_id) + except Exception as exc: + print(f"external probe error (on create): {exc}", flush=True) + return { + "id": service_id, + "name": body.name, + "url": body.url, + "public_url": body.public_url, + } @router.patch("/services/{service_id}") async def update_service(service_id: int, body: ServiceUpdate) -> dict: - if body.name is None and body.url is None: + if body.name is None and body.url is None and body.public_url is None: raise HTTPException(status_code=422, detail="nothing to update") db = get_db() + cursor = await db.execute("SELECT public_url FROM services WHERE id = ?", (service_id,)) + row = await cursor.fetchone() + if row is None: + raise HTTPException(status_code=404, detail="service not found") + + public_changed = body.public_url is not None and row["public_url"] != body.public_url if body.name is not None: await db.execute("UPDATE services SET name = ? WHERE id = ?", (body.name, service_id)) if body.url is not None: @@ -115,11 +199,19 @@ " WHERE id = ?", (body.url, service_id), ) - cursor = await db.execute("SELECT changes()") - (changed,) = await cursor.fetchone() - if not changed: - raise HTTPException(status_code=404, detail="service not found") + if public_changed: + # состояние алертов и история относились к другому домену: график не + # должен смешивать два сервиса, а открытый инцидент — переезжать + await db.execute( + "UPDATE services SET public_url = ?, external_bad_since = NULL," + " external_alert_open = 0 WHERE id = ?", + (body.public_url, service_id), + ) + await db.execute( + "DELETE FROM service_external_samples WHERE service_id = ?", (service_id,) + ) await db.commit() + probe = None if body.url is not None: # URL сменился — пробуем не дожидаясь волны try: @@ -127,7 +219,14 @@ await refresh_meta_now(service_id, body.url) except Exception as exc: print(f"services probe error (on update): {exc}", flush=True) - cursor = await db.execute("SELECT id, name, url, created_at FROM services WHERE id = ?", (service_id,)) + if public_changed and body.public_url: + try: + await probe_external_once(service_id) + except Exception as exc: + print(f"external probe error (on update): {exc}", flush=True) + cursor = await db.execute( + "SELECT id, name, url, public_url, created_at FROM services WHERE id = ?", (service_id,) + ) return dict(await cursor.fetchone()) | ({"probe": probe} if probe else {}) @@ -230,4 +329,48 @@ "report": report, } ) - return points \ No newline at end of file + return points + + +@router.get("/services/{service_id}/external_samples") +async def service_external_samples( + service_id: int, + since: datetime | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=5000), +) -> list[dict]: + """История внешних проб (публичный путь). По возрастанию ts.""" + db = get_db() + cursor = await db.execute("SELECT 1 FROM services WHERE id = ?", (service_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="service not found") + conditions = ["service_id = ?"] + params: list = [service_id] + if since is not None: + conditions.append("ts >= ?") + params.append(since.astimezone(timezone.utc).isoformat()) + params.append(limit) + cursor = await db.execute( + "SELECT ts, state, dns_ips, code, latency_ms, cert_days, message" + f" FROM service_external_samples WHERE {' AND '.join(conditions)}" + " ORDER BY ts DESC LIMIT ?", + params, + ) + rows = await cursor.fetchall() + points = [] + for row in reversed(rows): + try: + dns_ips = json.loads(row["dns_ips"]) if row["dns_ips"] else [] + except ValueError: + dns_ips = [] + points.append( + { + "ts": row["ts"], + "state": row["state"], + "dns_ips": dns_ips, + "code": row["code"], + "latency_ms": row["latency_ms"], + "cert_days": row["cert_days"], + "message": row["message"], + } + ) + return points diff --git a/panel/backend/app/config.py b/panel/backend/app/config.py index 8e2b1f6..415fc51 100644 --- a/panel/backend/app/config.py +++ b/panel/backend/app/config.py @@ -31,6 +31,18 @@ health_interval: int = 30 # таймаут одного health-запроса (сек) health_timeout: float = 5.0 + # --- внешняя доступность сервисов (задача #521) ------------------------- + # ожидаемый публичный IP площадки, на которой живут сервисы; пусто — + # проверяем публичный путь без сверки DNS с ожидаемым адресом + public_ip: str = "" + # публичный резолвер для сверки (в обход локального split-horizon DNS) + public_resolver: str = "1.1.1.1" + # как часто панель проверяет публичный путь (сек) + external_interval: int = 60 + # бюджет всей внешней пробы: DNS + TCP + TLS + строка статуса (сек) + external_timeout: float = 5.0 + # непрерывного отказа столько секунд — и только тогда событие в Synapse + external_alert_after: float = 300.0 # Gnexus Synapse (этап 4, репортер ивентов): пустой api_key = интеграция # выключена. default_source — зарегистрированное в админке Synapse имя synapse_url: str = "" diff --git a/panel/backend/app/db.py b/panel/backend/app/db.py index ca47039..1173fbc 100644 --- a/panel/backend/app/db.py +++ b/panel/backend/app/db.py @@ -46,6 +46,11 @@ await _try_alter("ALTER TABLE services ADD COLUMN site_title TEXT") await _try_alter("ALTER TABLE services ADD COLUMN icon TEXT") await _try_alter("ALTER TABLE services ADD COLUMN meta_fetched_at TEXT") + await _try_alter("ALTER TABLE services ADD COLUMN public_url TEXT NOT NULL DEFAULT ''") + await _try_alter("ALTER TABLE services ADD COLUMN external_bad_since TEXT") + await _try_alter( + "ALTER TABLE services ADD COLUMN external_alert_open INTEGER NOT NULL DEFAULT 0" + ) await _try_alter("ALTER TABLE metrics ADD COLUMN agg_json TEXT NOT NULL DEFAULT '{}'") await _conn.commit() diff --git a/panel/backend/app/events.py b/panel/backend/app/events.py index 367479d..79b3f7f 100644 --- a/panel/backend/app/events.py +++ b/panel/backend/app/events.py @@ -49,6 +49,11 @@ "service_down": Sink("service", "down", "high", True), "service_degraded": Sink("service", "degraded", "normal", True), "service_recovered": Sink("service", "recovered", "low", False), + # внешняя доступность: одно событие на инцидент уже гарантирует + # services.external_alert_open, поэтому дневной dedup Synapse не нужен — + # иначе второй реальный отказ за те же сутки был бы подавлен + "service_external_down": Sink("service", "external_down", "normal", False), + "service_external_recovered": Sink("service", "recovered", "low", False), **THRESHOLD_SINKS, } diff --git a/panel/backend/app/external_probe.py b/panel/backend/app/external_probe.py new file mode 100644 index 0000000..2ac3016 --- /dev/null +++ b/panel/backend/app/external_probe.py @@ -0,0 +1,527 @@ +"""Проверка внешней доступности сервисов (задача #521). + +Проба идёт по публичному пути, а не через локальный DNS: + + публичный резолвер → публичный IP → TLS с SNI и проверкой сертификата → + GET публичного пути → код ответа + +Так ловятся поломки, которых не видит внутренний health-чек +(`services_probe.py`): публичный DNS ведёт не на тот адрес, сертификат +истёк или выдан другому имени, порт закрыт снаружи, reverse proxy отвечает +5xx. Проба запускается с самой панели, поэтому внешний файрвол ПЕРЕД панелью +она не проверяет — это ограничение, а не баг. + +Состояния точки: + + up DNS ок (или ожидаемый IP не задан), TLS ок, код < 500 и не 421 + dns_mismatch ожидаемый IP не входит в ответ публичного DNS + dns_fail NXDOMAIN / нет A-AAAA / пустой ответ + tls_fail TLS-рукопожатие или проверка сертификата не прошли + http_fail нет/битая строка статуса, код >= 500 или 421 + down TCP: отказ, сброс, таймаут + probe_error недоступен сам резолвер — о сервисе ничего не известно, + поэтому такой замер НЕ участвует в алертах + +Свежесть точки и «pending» выводит API `GET /services`. История — 7 дней, +как у внутренних проб. +""" + +import asyncio +import ipaddress +import ssl +import time +from datetime import datetime, timedelta, timezone +from urllib.parse import urlparse + +import dns.asyncresolver +import dns.exception +import dns.resolver + +from app.config import get_settings +from app.db import get_db, j +from app.events import record_event + +RETENTION_DAYS = 7 +PORT = 443 +# пауза на закрытие TLS-соединения: wait_closed() на битом shutdown может висеть +CLOSE_TIMEOUT_S = 0.5 + +_warned_bad_ip = False + + +def _expected_ip() -> str: + """GHARD_PUBLIC_IP, если это валидный IP; иначе '' (сверка DNS выключена). + + Сломанную настройку не роняем на каждой волне: предупреждаем один раз. + """ + global _warned_bad_ip + value = get_settings().public_ip.strip() + if not value: + return "" + try: + ipaddress.ip_address(value) + except ValueError: + if not _warned_bad_ip: + print( + f"external probe: GHARD_PUBLIC_IP={value!r} не IP — сверка DNS отключена", + flush=True, + ) + _warned_bad_ip = True + return "" + return value + + +def _resolver(): + """Резолвер на публичный DNS: локальный split-horizon для сверки не годится. + + Создаётся внутри работающей петли (не на импорте): так его не привязывает + ни один завершившийся event loop. + """ + settings = get_settings() + resolver = dns.asyncresolver.Resolver(configure=False) + resolver.nameservers = [settings.public_resolver] + resolver.timeout = settings.external_timeout + resolver.lifetime = settings.external_timeout + return resolver + + +def _short(exc: Exception, limit: int = 200) -> str: + text = str(exc) or type(exc).__name__ + return text.split(";")[0][:limit] + + +async def _resolve(host: str, resolver, timeout: float) -> tuple[list[str], str, str]: + """(ips, state, message); state '' — ответ получен и непуст. + + Семейство запросов выбирается по ожидаемому IP: незачем спрашивать AAAA, + если площадка IPv4, и наоборот. Без ожидаемого IP спрашиваем обе семьи. + """ + expected = _expected_ip() + if expected and ":" in expected: + rdtypes = ("AAAA",) + elif expected: + rdtypes = ("A",) + else: + rdtypes = ("A", "AAAA") + + answers: list[str] = [] + resolver_failed = False + last_exc: Exception | None = None + for rdtype in rdtypes: + try: + answer = await resolver.resolve(host, rdtype, search=False, lifetime=timeout) + except dns.resolver.NXDOMAIN: + return [], "dns_fail", "NXDOMAIN: домена нет" + except dns.resolver.NoAnswer: + continue # записей этого семейства нет — проверим следующее + except dns.exception.DNSException as exc: + # Timeout/Lifetime/NoNameservers: резолвер не дал ответа, поэтому + # судить о сервисе нельзя (NoNameservers приходит и при блокировке + # UDP/53, и при отказавших NS — оба случая не про сервис) + resolver_failed = True + last_exc = exc + continue + answers.extend(str(record) for record in answer) + + if answers: + return answers, "", "" + if resolver_failed: + name = type(last_exc).__name__ if last_exc is not None else "DNSException" + return [], "probe_error", f"резолвер {get_settings().public_resolver} не ответил ({name})" + return [], "dns_fail", "нет A/AAAA записей" + + +def _parse_status(line: bytes) -> tuple[str, int | None, str]: + """Строка статуса HTTP/1.1 → (state, code, message).""" + if not line: + return "http_fail", None, "пустой ответ" + try: + text = line.decode("latin-1").strip() + _version, code_text, *_rest = text.split(" ", 2) + code = int(code_text) + except (ValueError, UnicodeError): + return "http_fail", None, f"битая строка статуса: {line[:60]!r}" + if code == 421: + return "http_fail", code, "421 (SNI/Host не совпал с виртуальным хостом)" + if code >= 500: + return "http_fail", code, f"http {code}" + # 3xx/4xx — площадка достижима; редиректы не идём: важен именно этот IP и SNI + return "up", code, "" + + +async def _tls_http_get(ip: str, host: str, target: str, timeout: float) -> dict: + """TLS с проверкой сертификата + один GET; читаем только строку статуса.""" + started = time.monotonic() + state = "down" + code: int | None = None + cert_days: int | None = None + message = "" + writer = None + try: + context = ssl.create_default_context() + context.set_alpn_protocols(["http/1.1"]) # h2 мы разбирать не умеем + async with asyncio.timeout(timeout): + reader, writer = await asyncio.open_connection( + ip, + PORT, + ssl=context, + server_hostname=host, + ssl_handshake_timeout=timeout, + ) + ssl_object = writer.get_extra_info("ssl_object") + cert = ssl_object.getpeercert() if ssl_object is not None else {} + if cert and cert.get("notAfter"): + cert_days = int( + (ssl.cert_time_to_seconds(cert["notAfter"]) - time.time()) // 86400 + ) + request = ( + f"GET {target} HTTP/1.1\r\n" + f"Host: {host}\r\n" + "User-Agent: ghard-panel-external/1\r\n" + "Accept: */*\r\n" + "Connection: close\r\n\r\n" + ) + writer.write(request.encode("ascii", "replace")) + await writer.drain() + line = await reader.readline() + state, code, message = _parse_status(line) + except ssl.SSLCertVerificationError as exc: + state, message = "tls_fail", f"сертификат: {_short(exc)}" + except ssl.SSLError as exc: + state, message = "tls_fail", f"tls: {_short(exc)}" + except TimeoutError: + state, message = "down", "таймаут соединения" + except OSError as exc: + state, message = "down", _short(exc) + finally: + if writer is not None: + try: + writer.close() + async with asyncio.timeout(CLOSE_TIMEOUT_S): + await writer.wait_closed() + except (TimeoutError, OSError, ssl.SSLError, asyncio.CancelledError): + pass + return { + "state": state, + "code": code, + "latency_ms": round((time.monotonic() - started) * 1000, 1), + "cert_days": cert_days, + "message": message, + } + + +def _tls_note(tls: dict | None) -> str: + """Вторичный результат TLS для `message` (не терять причину отказа).""" + if tls is None: + return "" + if tls["state"] == "up": + return "tls/HTTP ок" + return f"tls/HTTP: {tls['message'] or tls['state']}" + + +def _classify( + dns_state: str, + dns_message: str, + mismatch: bool, + ips: list[str], + expected: str, + tls: dict | None, +) -> tuple[str, str]: + """Итоговое состояние пробы и человекочитаемая причина. + + Порядок: недоступный резолвер → DNS-ошибка → расхождение DNS → TLS → + HTTP. Вторичный результат добавляется к сообщению, чтобы причина не + терялась (например, «DNS не туда, но TLS к ожидаемому IP жив»). + """ + if dns_state == "probe_error": + return "probe_error", dns_message + if dns_state == "dns_fail": + note = _tls_note(tls) + return "dns_fail", f"{dns_message} ({note})" if note else dns_message + if mismatch: + found = ", ".join(ips) or "—" + note = _tls_note(tls) + text = f"DNS: {found}, ожидался {expected}" + return "dns_mismatch", f"{text} ({note})" if note else text + if tls is None: + return "probe_error", dns_message or "проба не выполнена" + if tls["state"] != "up": + return tls["state"], tls["message"] + return "up", "" + + +async def probe_once(service: dict, resolver) -> dict: + """Одна внешняя проба сервиса → {state, code, latency_ms, dns_ips, + cert_days, message}. + + С ожидаемым IP TLS не зависит от DNS (идём на IP) — поэтому сверка DNS и + запрос выполняются параллельно и укладываются в один бюджет. Без + ожидаемого IP соединение возможно только после резолва, бюджет делится + пополам. + """ + settings = get_settings() + parsed = urlparse(service["public_url"]) + host = parsed.hostname or "" + target = parsed.path or "/" + if parsed.query: + target = f"{target}?{parsed.query}" + expected = _expected_ip() + + if expected: + dns_result, tls = await asyncio.gather( + _resolve(host, resolver, settings.external_timeout), + _tls_http_get(expected, host, target, settings.external_timeout), + ) + else: + dns_result = await _resolve(host, resolver, settings.external_timeout / 2) + tls = None + if not dns_result[1]: + tls = await _tls_http_get( + dns_result[0][0], host, target, settings.external_timeout / 2 + ) + + ips, dns_state, dns_message = dns_result + mismatch = bool(expected) and not dns_state and expected not in ips + state, message = _classify(dns_state, dns_message, mismatch, ips, expected, tls) + return { + "state": state, + "code": tls["code"] if tls is not None else None, + "latency_ms": tls["latency_ms"] if tls is not None else 0.0, + "dns_ips": ips, + "cert_days": tls["cert_days"] if tls is not None else None, + "message": message, + } + + +async def _internal_state(db, service_id: int) -> str: + """Последнее внутреннее состояние сервиса — для сообщения об алерте.""" + cursor = await db.execute( + "SELECT state FROM service_samples WHERE service_id = ? ORDER BY id DESC LIMIT 1", + (service_id,), + ) + row = await cursor.fetchone() + return row["state"] if row is not None else "" + + +def _event_data(service: dict, probe: dict, internal_state: str) -> dict: + data = { + "service_id": service["id"], + "name": service["name"], + "public_url": service["public_url"], + "state": probe["state"], + "message": probe["message"], + "dns_ips": probe["dns_ips"], + "internal_state": internal_state, + } + if probe.get("code") is not None: + data["code"] = probe["code"] + if probe.get("cert_days") is not None: + data["cert_days"] = probe["cert_days"] + return data + + +async def _alert_step( + db, service: dict, probe: dict, last_ts: str | None, now: datetime +) -> None: + """Конечный автомат алертов: одно событие на инцидент, recovered — только + после реального down-события (короткое моргание Synapse не будит). + + Состояние живёт в колонках services (`external_bad_since` / + `external_alert_open`) и переживает рестарт панели. Если между замерами + был разрыв больше 3 интервалов, сервис не наблюдался — окно отказа + начинается заново, чтобы не приписать ему падение, которого мы не видели. + Открытый алерт при этом не сбрасываем: повторного down не будет. + """ + state = probe["state"] + service_id = service["id"] + if state == "probe_error": # о сервисе ничего не известно + return + if state == "up": + if service["external_alert_open"]: + since = _parse_ts(service["external_bad_since"]) + minutes = max(1, int((now - since).total_seconds() // 60)) if since else 0 + message = f"{service['name']} снова доступен снаружи" + if minutes: + message += f" (отказ длился {minutes} мин)" + await record_event( + db, + type="service_external_recovered", + severity="info", + message=message, + data=_event_data(service, probe, await _internal_state(db, service_id)), + ) + await db.execute( + "UPDATE services SET external_alert_open = 0, external_bad_since = NULL" + " WHERE id = ?", + (service_id,), + ) + elif service["external_bad_since"] is not None: + await db.execute( + "UPDATE services SET external_bad_since = NULL WHERE id = ?", (service_id,) + ) + return + + settings = get_settings() + bad_since = _parse_ts(service["external_bad_since"]) + if bad_since is not None and last_ts is not None: + last = _parse_ts(last_ts) + if last is not None and (now - last).total_seconds() > settings.external_interval * 3: + bad_since = None + if bad_since is None: + bad_since = now + await db.execute( + "UPDATE services SET external_bad_since = ? WHERE id = ?", + (bad_since.isoformat(), service_id), + ) + if service["external_alert_open"]: + return + if (now - bad_since).total_seconds() < settings.external_alert_after: + return + + internal_state = await _internal_state(db, service_id) + minutes = max(1, int((now - bad_since).total_seconds() // 60)) + message = ( + f"{service['name']} недоступен снаружи {minutes} мин" + f" ({state}: {probe['message']})" + ) + if internal_state == "up": + message += " — внутри сервис отвечает" + await record_event( + db, + type="service_external_down", + severity="warning", + message=message, + data=_event_data(service, probe, internal_state), + ) + await db.execute( + "UPDATE services SET external_alert_open = 1 WHERE id = ?", (service_id,) + ) + + +def _parse_ts(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +async def _last_ts(db, service_ids: list[int]) -> dict[int, str]: + """ts последней внешней точки до вставки новой.""" + result: dict[int, str] = {} + for service_id in service_ids: + cursor = await db.execute( + "SELECT ts FROM service_external_samples WHERE service_id = ?" + " ORDER BY id DESC LIMIT 1", + (service_id,), + ) + row = await cursor.fetchone() + if row is not None: + result[service_id] = row["ts"] + return result + + +def _fallback_probe(exc: Exception) -> dict: + """Упавшая проба одного сервиса: волну не роняем, пишем probe_error.""" + return { + "state": "probe_error", + "code": None, + "latency_ms": 0.0, + "dns_ips": [], + "cert_days": None, + "message": _short(exc), + } + + +async def sample_once() -> int: + """Одна волна внешних проб по всем сервисам с публичным URL.""" + db = get_db() + cursor = await db.execute( + "SELECT id, name, public_url, external_bad_since, external_alert_open" + " FROM services WHERE public_url != ''" + ) + rows = await cursor.fetchall() + if not rows: + return 0 + + services = [dict(row) for row in rows] + resolver = _resolver() + last_ts = await _last_ts(db, [service["id"] for service in services]) + results = await asyncio.gather( + *(probe_once(service, resolver) for service in services), + return_exceptions=True, + ) + + now = datetime.now(timezone.utc) + ts = now.isoformat() + for service, result in zip(services, results): + probe = _fallback_probe(result) if isinstance(result, Exception) else result + await db.execute( + "INSERT INTO service_external_samples" + " (service_id, ts, state, dns_ips, code, latency_ms, cert_days, message)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + service["id"], + ts, + probe["state"], + j(probe["dns_ips"]), + probe["code"], + probe["latency_ms"], + probe["cert_days"], + probe["message"], + ), + ) + await _alert_step(db, service, probe, last_ts.get(service["id"]), now) + + cutoff = (now - timedelta(days=RETENTION_DAYS)).isoformat() + await db.execute("DELETE FROM service_external_samples WHERE ts < ?", (cutoff,)) + await db.commit() + return len(services) + + +async def probe_service_once(service_id: int) -> dict | None: + """Немедленная проба после создания сервиса или смены публичного URL.""" + db = get_db() + cursor = await db.execute( + "SELECT id, name, public_url, external_bad_since, external_alert_open" + " FROM services WHERE id = ?", + (service_id,), + ) + row = await cursor.fetchone() + if row is None or not row["public_url"]: + return None + + service = dict(row) + last_ts = (await _last_ts(db, [service_id])).get(service_id) + probe = await probe_once(service, _resolver()) + now = datetime.now(timezone.utc) + await db.execute( + "INSERT INTO service_external_samples" + " (service_id, ts, state, dns_ips, code, latency_ms, cert_days, message)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + service_id, + now.isoformat(), + probe["state"], + j(probe["dns_ips"]), + probe["code"], + probe["latency_ms"], + probe["cert_days"], + probe["message"], + ), + ) + await _alert_step(db, service, probe, last_ts, now) + await db.commit() + return probe + + +async def loop() -> None: + interval = get_settings().external_interval + while True: + try: + await sample_once() + except Exception as exc: # не роняем цикл из-за одной ошибки + print(f"external probe error: {exc}", flush=True) + await asyncio.sleep(interval) diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index 0b0297d..d38ebca 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -13,6 +13,7 @@ from app.api import auth_routes, events, ingest, mcp_tokens, servers, services, shares from app.db import close_db, init_db, vacuum_if_needed from app.events import offline_loop +from app.external_probe import loop as external_probe_loop from app.mcp import mcp, mcp_endpoint from app.metrics_retention import retention_loop, run_retention_pass from app.services_probe import loop as services_probe_loop @@ -42,6 +43,8 @@ probe_task = asyncio.create_task(shares_probe_loop()) # фоновый пробер health-эндпоинтов сервисов (service_samples) health_task = asyncio.create_task(services_probe_loop()) + # внешняя доступность сервисов по публичному пути (service_external_samples) + external_task = asyncio.create_task(external_probe_loop()) # подчистка expired-сессий и oauth state (каждые 10 минут) gc_task = asyncio.create_task(auth_gc_loop()) # watchdog offline (нет пакетов дольше interval * multiplier) + ретеншн ивентов @@ -54,6 +57,7 @@ yield probe_task.cancel() health_task.cancel() + external_task.cancel() gc_task.cancel() watchdog_task.cancel() retention_task.cancel() diff --git a/panel/backend/app/mcp.py b/panel/backend/app/mcp.py index 4424ab8..baeff7b 100644 --- a/panel/backend/app/mcp.py +++ b/panel/backend/app/mcp.py @@ -57,7 +57,10 @@ апптайм, заметка пользователя. - **Сервисы** — health-чеки внешних HTTP-эндпоинтов (gnexus-сервисы: /health и похожие). Панель пробует их по расписанию и хранит историю - откликов + журнал моментов недоступности. + откликов + журнал моментов недоступности. Если у сервиса задан публичный + URL, панель отдельно проверяет публичный путь (DNS → ожидаемый IP → TLS + с SNI → HTTP) и отдаёт поле `external`; внутренний и внешний статусы + могут расходиться — это и есть смысл проверки снаружи. - **Сетевые хранилища** — примонтированные NAS-шары и их заполнение. - **Журнал событий** — всё, что панель зафиксировала: offline/пороги/ контейнеры/сервисы (хранение — год). @@ -88,8 +91,9 @@ агента сервер остаётся `pending`. - `server_delete(server_id)` — удаляет сервер **необратимо**: его метрики и журнал событий стираются каскадом. -- `service_create(name, url)` — добавляет health-чек; первая проба - выполняется сразу, статус читай через `services_overview`. +- `service_create(name, url, public_url="")` — добавляет health-чек; первая + проба выполняется сразу, статус читай через `services_overview`. + `public_url` (необязательный, только https) включает внешнюю проверку. - `service_delete(service_id)` — удаляет сервис; история его проб стирается. Правила записи: @@ -102,10 +106,17 @@ ## Словарь статусов - Сервер: `online` (пакеты идут) / `offline` (панель не получает метрики — агент умер, сеть или машина выключена) / данных нет. -- Сервис: `up` (здоров) / `degraded` (работает, но сообщает о деградации) / - `down` (не отвечает) / `pending` (данных ещё нет). Поле `mode` +- Сервис **изнутри**: `up` (здоров) / `degraded` (работает, но сообщает о + деградации) / `down` (не отвечает) / `pending` (данных ещё нет). Поле `mode` (maintenance / readonly / draining) — это **режим работы**, а не падение; не пугай пользователя режимом. +- Сервис **снаружи** (`external.status`): `up` / `dns_mismatch` (публичный DNS + ведёт не на ожидаемый IP площадки) / `dns_fail` (домена или A/AAAA нет) / + `tls_fail` (сертификат или SNI) / `http_fail` (5xx или 421) / `down` (TCP + закрыт или таймаут) / `probe_error` (недоступен сам резолвер, о сервисе + ничего не известно) / `pending`. Внутренний `status` и `external.status` — + разные вещи: «внутри up, снаружи down» значит, что сервис работает, а + публичный вход сломан. - Журнал: severity `info` / `warning` / `critical`. ## Словарь событий (поле type в журнале) @@ -117,7 +128,11 @@ `container_exited` — docker-контейнер появился/исчез/запустился/завершился (в payload: name, image, exit_code если есть). - `service_down` / `service_degraded` / `service_recovered` — сервис - перестал отвечать / деградировал / восстановился. + перестал отвечать / деградировал / восстановился (изнутри). +- `service_external_down` / `service_external_recovered` — публичный путь + недоступен дольше 5 минут / снова доступен. В payload: state (dns_mismatch, + tls_fail, ...), dns_ips, cert_days и internal_state — что видел внутренний + health-чек в это время. - Пороги сжимаются в человеческую речь, например: `cpu 95% (≥90%)`, `disk / 98% (≥90%)`, `load1 2.1×cores (≥2.0×)`. @@ -177,6 +192,17 @@ return json.dumps({"error": code, "detail": detail}, ensure_ascii=False) +def _external_note(service: dict) -> str: + """«, снаружи down (… )» для обзоров; пусто, если внешней проверки нет.""" + external = service.get("external") + if not external: + return "" + note = f", снаружи {external['status']}" + if external.get("message"): + note += f" ({external['message']})" + return note + + # Rate-limit записи (канон mcp.md: чувствительные операции). Окно в памяти # процесса, глобальное — тула не видит личность звонящего (FastMCP этой # версии не пробрасывает HTTP-контекст), для защиты от спама хватает. @@ -239,7 +265,8 @@ mode = f", режим {report['mode']}" if report.get("mode") else "" msg = f" ({svc['message']})" if svc.get("message") else "" lines.append( - f"#{svc['id']} {svc['name']} [{svc['status']}] {svc['latency_ms'] or '—'} ms{identity}{mode}{msg}" + f"#{svc['id']} {svc['name']} [{svc['status']}] {svc['latency_ms'] or '—'} ms" + f"{identity}{mode}{msg}{_external_note(svc)}" ) shares = await _list_shares() if shares: @@ -342,7 +369,7 @@ msg = f" ({svc['message']})" if svc.get("message") else "" lines.append( f"#{svc['id']} {svc['name']} [{svc['status']}] " - f"{svc['latency_ms'] or '—'} ms{code}{mode}{msg}{id_str}" + f"{svc['latency_ms'] or '—'} ms{code}{mode}{msg}{id_str}{_external_note(svc)}" ) return "\n".join(lines) @@ -421,19 +448,31 @@ @mcp.tool() -async def service_create(name: str, url: str) -> str: +async def service_create(name: str, url: str, public_url: str = "") -> str: """Добавить health-чек сервиса (name до 100 симв., url до 500). + public_url — необязательный публичный https-URL для внешней проверки. Первая проба выполняется сразу; статус — через services_overview.""" if gate := _write_gate(): return gate if not url.strip(): return _err(422, "url обязателен") try: - created = await _create_service(_ServiceCreate(name=name.strip(), url=url.strip())) + created = await _create_service( + _ServiceCreate(name=name.strip(), url=url.strip(), public_url=public_url.strip()) + ) except ValidationError: - return _err(422, "name: 1..100 символов, url: 1..500 символов") + return _err( + 422, + "name: 1..100 символов, url: 1..500 символов, " + "public_url: https:// без логина/#, порт 443", + ) + external = ( + f"\nВнешняя проверка включена: {created['public_url']}" + if created.get("public_url") + else "" + ) return ( - f"Сервис #{created['id']} «{created['name']}» добавлен: {created['url']}\n" + f"Сервис #{created['id']} «{created['name']}» добавлен: {created['url']}{external}\n" f"Первая проба выполнена (или доберёт фоновый цикл) — статус читай в services_overview." ) diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index 291a5ab..51aca34 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -101,10 +101,14 @@ CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - url TEXT NOT NULL, -- полный health URL сервиса + url TEXT NOT NULL, -- полный health URL сервиса (изнутри) + public_url TEXT NOT NULL DEFAULT '', -- публичный URL (снаружи); '' = не проверяем site_title TEXT, --