"""Веб-панель: хотспоты, группы, живой журнал, настройки. Работает на stdlib `http.server` в отдельном потоке — так проще и надёжнее, чем писать разбор HTTP поверх asyncio. Нагрузка тут никакая: несколько хотспотов и одна-две открытые вкладки. Авторизация — HTTP Basic. Для внутренней сети этого достаточно, а формы с сессиями потребовали бы куки, CSRF и хранение сессий ради того же результата. """ from __future__ import annotations import hmac import json import threading import queue as _queue from base64 import b64decode from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse from . import config as cfgmod from .page import PAGE from .log import get_logger log = get_logger(__name__) def _valid_id_token(s: str) -> bool: """Число или диапазон «нач-кон». Проверяем в панели, чтобы человек сразу увидел опечатку, а не гадал, почему абонента не пускает.""" s = s.strip() parts = s.split("-", 1) if "-" in s else [s] return all(p.strip().isdigit() and 1 <= int(p) <= 16777215 for p in parts) and len(parts) <= 2 def _make_handler(ctx): cfg, state = ctx["cfg"], ctx["state"] drop_peer = ctx["drop_peer"] class Handler(BaseHTTPRequestHandler): server_version = "dmrsrv" def log_message(self, fmt, *args): # не засорять journal каждым GET pass # ── авторизация ───────────────────────────────────────────────────── def _authed(self) -> bool: hdr = self.headers.get("Authorization", "") if not hdr.startswith("Basic "): return False try: user, _, pwd = b64decode(hdr[6:]).decode("utf-8").partition(":") except Exception: return False p = cfg["panel"] # сравнение постоянного времени: иначе пароль подбирается по таймингу return (hmac.compare_digest(user, p["user"]) and hmac.compare_digest(pwd, p["password"])) def _deny(self) -> None: self.send_response(401) self.send_header("WWW-Authenticate", 'Basic realm="DMR"') self.send_header("Content-Length", "0") self.end_headers() def _json(self, obj, code: int = 200) -> None: body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _html(self, text: str) -> None: body = text.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) # ── маршруты ──────────────────────────────────────────────────────── def do_GET(self) -> None: if not self._authed(): return self._deny() path = urlparse(self.path).path if path == "/": return self._html(PAGE) if path == "/api/state": snap = state.snapshot() snap["groups"] = cfg.get("groups", []) snap["parrot"] = cfg.get("parrot", {}) snap["hotspots"] = cfg.get("hotspots", []) snap["subscribers"] = cfg.get("subscribers", {"mode": "off", "list": []}) snap["allow_unknown"] = cfg.get("allow_unknown_hotspots", True) snap["port"] = cfg.get("port") return self._json(snap) if path == "/api/events": return self._sse() self.send_error(404) def _sse(self) -> None: """Живой поток событий (Server-Sent Events). Заменяет опрос раз в две секунды: события уходят в браузер сразу, как только случились. Каждое соединение обслуживается своим потоком (ThreadingHTTPServer), очередь ограничена — зависший браузер не затормозит приём эфира. """ q = state.subscribe() try: self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "keep-alive") self.send_header("X-Accel-Buffering", "no") # чтобы не буферизовал прокси self.end_headers() # Сразу отдаём хвост журнала, иначе после перезагрузки страницы пусто. for rec in state.snapshot()["log"][-100:]: self._sse_send(rec) while True: try: rec = q.get(timeout=15) except _queue.Empty: self.wfile.write(b": keepalive\n\n") # держим соединение живым self.wfile.flush() continue self._sse_send(rec) except (BrokenPipeError, ConnectionResetError, OSError): pass # вкладку закрыли — это норма finally: state.unsubscribe(q) def _sse_send(self, rec) -> None: self.wfile.write(b"data: " + json.dumps(rec, ensure_ascii=False).encode("utf-8") + b"\n\n") self.wfile.flush() def do_POST(self) -> None: if not self._authed(): return self._deny() path = urlparse(self.path).path try: n = int(self.headers.get("Content-Length", "0")) data = json.loads(self.rfile.read(n) or b"{}") except (ValueError, json.JSONDecodeError): return self._json({"error": "плохой JSON"}, 400) if path == "/api/groups/add": try: tg, slot = int(data["tg"]), int(data.get("slot", 2)) except (KeyError, ValueError, TypeError): return self._json({"error": "нужны tg и slot"}, 400) if slot not in (1, 2): return self._json({"error": "слот только 1 или 2"}, 400) if any(int(g["tg"]) == tg and int(g["slot"]) == slot for g in cfg["groups"]): return self._json({"error": f"TG{tg} на слоте {slot} уже есть"}, 400) cfg["groups"].append({"tg": tg, "name": str(data.get("name", ""))[:40], "slot": slot}) cfgmod.save(cfg) state.event("info", f"панель: добавлена группа TG{tg} (слот {slot})") return self._json({"ok": True, "groups": cfg["groups"]}) if path == "/api/groups/del": try: tg, slot = int(data["tg"]), int(data["slot"]) except (KeyError, ValueError, TypeError): return self._json({"error": "нужны tg и slot"}, 400) before = len(cfg["groups"]) cfg["groups"] = [g for g in cfg["groups"] if not (int(g["tg"]) == tg and int(g["slot"]) == slot)] if len(cfg["groups"]) == before: return self._json({"error": "такой группы нет"}, 404) cfgmod.save(cfg) state.event("info", f"панель: удалена группа TG{tg} (слот {slot})") return self._json({"ok": True, "groups": cfg["groups"]}) if path == "/api/hotspots/add": try: pid = int(data["peer_id"]) except (KeyError, ValueError, TypeError): return self._json({"error": "нужен peer_id (номер хотспота)"}, 400) if not (1 <= pid <= 0xFFFFFFFF): return self._json({"error": "peer_id вне допустимого диапазона"}, 400) if any(int(h["peer_id"]) == pid for h in cfg["hotspots"]): return self._json({"error": f"хотспот {pid} уже в списке"}, 400) pw = str(data.get("password", "")) if pw and len(pw) < 4: return self._json({"error": "пароль слишком короткий"}, 400) cfg["hotspots"].append({"peer_id": pid, "password": pw, "name": str(data.get("name", ""))[:40], "enabled": True}) cfgmod.save(cfg) state.event("info", f"панель: добавлен хотспот {pid}" + (" (свой пароль)" if pw else " (общий пароль)")) return self._json({"ok": True, "hotspots": cfg["hotspots"]}) if path == "/api/hotspots/del": try: pid = int(data["peer_id"]) except (KeyError, ValueError, TypeError): return self._json({"error": "нужен peer_id"}, 400) before = len(cfg["hotspots"]) cfg["hotspots"] = [h for h in cfg["hotspots"] if int(h["peer_id"]) != pid] if len(cfg["hotspots"]) == before: return self._json({"error": "такого хотспота нет в списке"}, 404) cfgmod.save(cfg) state.event("info", f"панель: хотспот {pid} удалён из списка") return self._json({"ok": True, "hotspots": cfg["hotspots"]}) if path == "/api/hotspots/toggle": try: pid = int(data["peer_id"]) except (KeyError, ValueError, TypeError): return self._json({"error": "нужен peer_id"}, 400) for h in cfg["hotspots"]: if int(h["peer_id"]) == pid: h["enabled"] = not h.get("enabled", True) cfgmod.save(cfg) st = "включён" if h["enabled"] else "отключён" state.event("warn", f"панель: хотспот {pid} {st}") # отключили — рвём текущую сессию, иначе он останется в эфире if not h["enabled"]: drop_peer(pid) return self._json({"ok": True, "enabled": h["enabled"]}) return self._json({"error": "такого хотспота нет"}, 404) if path == "/api/subscribers": mode = str(data.get("mode", "off")).lower() if mode not in ("off", "allow", "deny"): return self._json({"error": "режим: off, allow или deny"}, 400) raw = data.get("list", []) if isinstance(raw, str): raw = [x for x in raw.replace(",", " ").split() if x] bad = [x for x in raw if not _valid_id_token(str(x))] if bad: return self._json({"error": "непонятные записи: " + ", ".join(map(str, bad[:5]))}, 400) cfg["subscribers"] = {"mode": mode, "list": [str(x) for x in raw]} cfgmod.save(cfg) names = {"off": "выключены", "allow": "белый список", "deny": "чёрный список"} state.event("warn", f"панель: списки абонентов — {names[mode]}, записей: {len(raw)}") return self._json({"ok": True, "subscribers": cfg["subscribers"]}) if path == "/api/settings": changed = [] if "passphrase" in data: pw = str(data["passphrase"]) if len(pw) < 4: return self._json({"error": "пароль хотспотов слишком короткий"}, 400) cfg["passphrase"] = pw changed.append("пароль хотспотов (применится при следующем входе)") if "parrot_enabled" in data: cfg["parrot"]["enabled"] = bool(data["parrot_enabled"]) changed.append("попугай " + ("включён" if cfg["parrot"]["enabled"] else "выключен")) if "allow_unknown_hotspots" in data: cfg["allow_unknown_hotspots"] = bool(data["allow_unknown_hotspots"]) changed.append("незнакомые хотспоты: " + ("пускать" if cfg["allow_unknown_hotspots"] else "только из списка")) if not changed: return self._json({"error": "нечего менять"}, 400) cfgmod.save(cfg) state.event("info", "панель: " + "; ".join(changed)) return self._json({"ok": True, "changed": changed}) if path == "/api/peer/kick": try: pid = int(data["peer_id"]) except (KeyError, ValueError, TypeError): return self._json({"error": "нужен peer_id"}, 400) if drop_peer(pid): state.event("warn", f"панель: хотспот {pid} отключён вручную") return self._json({"ok": True}) return self._json({"error": "нет такого хотспота"}, 404) self.send_error(404) return Handler def start(cfg: dict, state, drop_peer) -> ThreadingHTTPServer: p = cfg["panel"] srv = ThreadingHTTPServer((p["bind"], int(p["port"])), _make_handler( {"cfg": cfg, "state": state, "drop_peer": drop_peer})) srv.daemon_threads = True threading.Thread(target=srv.serve_forever, name="panel", daemon=True).start() log.info("панель: http://%s:%s (пользователь %s)", p["bind"], p["port"], p["user"]) return srv