Files
viktorиClaude Opus 4.8 cb4e8a9bb1 панель: многостраничная, Last Heard, SSE, контроль доступа
Панель:
 • шесть страниц с маршрутизацией по хэшу: Обзор, Last Heard, Хотспоты,
   Группы, Доступ, Настройки;
 • журнал приходит живым потоком (SSE) вместо опроса раз в две секунды;
 • Last Heard: кто, куда, когда, сколько, через какой хотспот;
 • в таблице хотспотов — ВСЁ, что устройство сообщает в RPTC.

Контроль доступа (два независимых уровня, их легко перепутать):
 • хотспот пускается по паролю — у каждого может быть свой, плюс режим
   «только из списка» и отключение конкретного устройства;
 • абонент (поле src, владелец рации) допускается белым/чёрным списком с
   поддержкой диапазонов. Встроено в штатный хук guard ядра.

Два бага по дороге:
 • поля RPTC читались как colorcode/latitude/longitude, а разборщик отдаёт
   color_code/lat/lon — половина таблицы молча пустовала;
 • столбец «описание» на деле содержит СТРАНУ: именно туда Pi-Star кладёт
   country. Переименован.

tests/test_access.py 20/20, tests/test_panel.py 31/31 (включая наполнение
Last Heard настоящим рукопожатием и голосовым вызовом по UDP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvX94Vwy7vXWEuiFm1g9Av
2026-07-29 01:32:46 +09:00

285 строки
15 KiB
Python

"""Веб-панель: хотспоты, группы, живой журнал, настройки.
Работает на 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