diff --git a/CONFIG.en.md b/CONFIG.en.md index 5a2782e..d63a572 100644 --- a/CONFIG.en.md +++ b/CONFIG.en.md @@ -89,6 +89,12 @@ Token flow: - First token: `POST /api/tokens` with `identity` and `secret`. - Refresh: `GET /api/tokens` using the cached token. +## gitea + +- `base_url` (string): Gitea base url, for example `http://localhost:3000`. +- `token` (string): Optional API token. +- `verify_tls` (bool): Set to `false` for self-signed TLS. + ## security - `reboot_password` (string): Password required before reboot. diff --git a/CONFIG.md b/CONFIG.md index a15ec48..883a05c 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -89,6 +89,12 @@ - первый токен: `POST /api/tokens` с `identity` и `secret`. - refresh: `GET /api/tokens` с текущим токеном. +## gitea + +- `base_url` (string): base url Gitea, например `http://localhost:3000`. +- `token` (string): опциональный API токен. +- `verify_tls` (bool): `false` для self-signed TLS. + ## security - `reboot_password` (string): пароль для подтверждения reboot. diff --git a/config.example.yaml b/config.example.yaml index 27863c1..2915a24 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -78,6 +78,12 @@ npmplus: cooldown_sec: 86400 interval_sec: 3600 +gitea: + base_url: "http://localhost:3000" + # Optional API token for private instances + token: "" + verify_tls: true + security: reboot_password: "CHANGE_ME" diff --git a/handlers/system.py b/handlers/system.py index c4af522..5536259 100644 --- a/handlers/system.py +++ b/handlers/system.py @@ -11,6 +11,7 @@ from services.queue import enqueue from services.updates import list_updates, apply_updates from services.runner import run_cmd from services.npmplus import fetch_certificates, format_certificates, list_proxy_hosts, set_proxy_host +from services.gitea import get_gitea_health import state from state import UPDATES_CACHE, REBOOT_PENDING from services.metrics import summarize @@ -246,6 +247,23 @@ async def ssl_certs(msg: Message): asyncio.create_task(worker()) +@dp.message(F.text == "🍵 Gitea") +async def gitea_health(msg: Message): + if not is_admin_msg(msg): + return + + await msg.answer("⏳ Checking Gitea health…", reply_markup=system_logs_kb) + + async def worker(): + try: + text = await asyncio.to_thread(get_gitea_health, cfg) + except Exception as e: + text = f"⚠️ Gitea error: {e}" + await msg.answer(text, reply_markup=system_logs_kb) + + asyncio.create_task(worker()) + + @dp.message(F.text == "🧩 NPMplus") async def npmplus_hosts(msg: Message): if not is_admin_msg(msg): diff --git a/keyboards.py b/keyboards.py index 9071f51..5fdda20 100644 --- a/keyboards.py +++ b/keyboards.py @@ -83,7 +83,7 @@ system_logs_kb = ReplyKeyboardMarkup( keyboard=[ [KeyboardButton(text="🧾 Audit"), KeyboardButton(text="📣 Incidents")], [KeyboardButton(text="🧰 Processes"), KeyboardButton(text="🔒 SSL")], - [KeyboardButton(text="🔑 SSH log"), KeyboardButton(text="🧩 NPMplus")], + [KeyboardButton(text="🔑 SSH log"), KeyboardButton(text="🧩 NPMplus"), KeyboardButton(text="🍵 Gitea")], [KeyboardButton(text="🌍 External"), KeyboardButton(text="🌐 URLs")], [KeyboardButton(text="⬅️ System")], ], diff --git a/services/gitea.py b/services/gitea.py new file mode 100644 index 0000000..428d2f7 --- /dev/null +++ b/services/gitea.py @@ -0,0 +1,88 @@ +import json +import ssl +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +def _request(url: str, headers: dict[str, str], verify_tls: bool) -> tuple[int, str]: + context = None + if not verify_tls: + context = ssl._create_unverified_context() # nosec - config-controlled + + req = Request(url, headers=headers) + try: + with urlopen(req, timeout=10, context=context) as resp: + body = resp.read().decode("utf-8") + return int(resp.status), body + except HTTPError as e: + try: + body = e.read().decode("utf-8") + except Exception: + body = "" + return int(e.code), body + except URLError as e: + raise RuntimeError(str(e.reason)) from e + + +def _api_base(cfg: dict[str, Any]) -> str: + g_cfg = cfg.get("gitea", {}) + base = (g_cfg.get("base_url") or "").rstrip("/") + return base + + +def get_gitea_health(cfg: dict[str, Any]) -> str: + g_cfg = cfg.get("gitea", {}) + base = _api_base(cfg) + verify_tls = g_cfg.get("verify_tls", True) + if not base: + return "⚠️ Gitea base_url not configured" + + token = (g_cfg.get("token") or "").strip() + headers = {"User-Agent": "tg-admin-bot"} + if token: + headers["Authorization"] = f"token {token}" + + lines = ["🍵 Gitea\n"] + + health_paths = ["/api/healthz", "/api/v1/healthz"] + health_status = None + health_payload = None + for path in health_paths: + status, body = _request(f"{base}{path}", headers, verify_tls) + if status == 200: + health_status = (status, path) + try: + health_payload = json.loads(body) + except json.JSONDecodeError: + health_payload = None + break + if status not in (404, 405): + health_status = (status, path) + break + + if health_status: + status, path = health_status + icon = "🟢" if status == 200 else "🔴" + if status == 200 and isinstance(health_payload, dict): + state = health_payload.get("status") or "ok" + checks = health_payload.get("checks") or {} + checks_total = len(checks) if isinstance(checks, dict) else 0 + lines.append(f"{icon} API health: {state} ({checks_total} checks)") + else: + lines.append(f"{icon} API health: {status} ({path})") + else: + lines.append("🟡 API health: endpoint not found") + + ver_status, ver_body = _request(f"{base}/api/v1/version", headers, verify_tls) + if ver_status == 200: + try: + payload = json.loads(ver_body) + except json.JSONDecodeError: + payload = {} + version = payload.get("version") or "unknown" + lines.append(f"ℹ️ Version: {version}") + else: + lines.append(f"🟡 Version: HTTP {ver_status}") + + return "\n".join(lines)