Панель: • шесть страниц с маршрутизацией по хэшу: Обзор, 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
132 строки
5.9 KiB
Python
132 строки
5.9 KiB
Python
"""Доступ: пароли хотспотов и списки абонентов, которым разрешён эфир.
|
|
|
|
Два независимых уровня — их легко перепутать, поэтому разделены явно:
|
|
|
|
1. ХОТСПОТ (оборудование) пускается по паролю на этапе RPTK. У каждого может быть
|
|
свой пароль; можно оставить общий для всех.
|
|
2. АБОНЕНТ (владелец рации, поле src в кадре) может быть допущен или закрыт для
|
|
выхода в эфир. Проверяется на каждой передаче.
|
|
|
|
Второе не следует из первого: на одном хотспоте работают разные люди, и запретить
|
|
конкретного абонента, не отключая всё устройство, — обычное требование.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
|
|
from .log import get_logger
|
|
|
|
log = get_logger(__name__)
|
|
|
|
# Диапазон DMR ID: 24 бита, ноль не абонент.
|
|
ID_MIN, ID_MAX = 1, 16777215
|
|
|
|
|
|
def parse_ids(items) -> set[int]:
|
|
"""Список ID из конфига: числа и диапазоны вида "2501000-2501999".
|
|
|
|
Терпимо к мусору: одна кривая строка не должна ронять сервер и не должна
|
|
молча открывать доступ — просто пропускаем её с предупреждением.
|
|
"""
|
|
out: set[int] = set()
|
|
for raw in items or []:
|
|
s = str(raw).strip()
|
|
if not s:
|
|
continue
|
|
try:
|
|
if "-" in s:
|
|
a, b = s.split("-", 1)
|
|
lo, hi = int(a), int(b)
|
|
if lo > hi:
|
|
lo, hi = hi, lo
|
|
if hi - lo > 100000: # защита от «0-16777215» одной строкой
|
|
log.warning("список ID: диапазон %s слишком широкий, пропущен", s)
|
|
continue
|
|
out.update(range(lo, hi + 1))
|
|
else:
|
|
out.add(int(s))
|
|
except ValueError:
|
|
log.warning("список ID: не разобрал запись %r, пропущена", raw)
|
|
return out
|
|
|
|
|
|
def hotspot_entry(cfg: dict, peer_id: int) -> dict | None:
|
|
for h in cfg.get("hotspots", []):
|
|
try:
|
|
if int(h.get("peer_id")) == int(peer_id):
|
|
return h
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def make_password_for(cfg: dict, state):
|
|
"""Колбэк для мастера: пароль хотспота или None (не пускать).
|
|
|
|
None на этапе RPTL/RPTK = отказ, хотспот получит MSTNAK и будет пробовать снова.
|
|
"""
|
|
|
|
def password_for(peer_id: int) -> bytes | None:
|
|
h = hotspot_entry(cfg, peer_id)
|
|
if h is not None:
|
|
if not h.get("enabled", True):
|
|
state.event("warn", f"хотспот {peer_id} ({h.get('name','')}) отключён в панели — отказ")
|
|
return None
|
|
pw = h.get("password") or cfg.get("passphrase", "")
|
|
return str(pw).encode("utf-8")
|
|
if cfg.get("allow_unknown_hotspots", True):
|
|
return str(cfg.get("passphrase", "")).encode("utf-8")
|
|
state.event("warn", f"хотспот {peer_id} не зарегистрирован — отказ "
|
|
f"(включён режим «только из списка»)")
|
|
return None
|
|
|
|
return password_for
|
|
|
|
|
|
class Guard:
|
|
"""Пускать ли абонента в эфир. Интерфейс задан ядром: check() -> (allow, reason)."""
|
|
|
|
def __init__(self, cfg: dict, state) -> None:
|
|
self.cfg = cfg
|
|
self.state = state
|
|
self.dropped: Counter = Counter()
|
|
self._last_note: dict[int, int] = {} # src -> последний залогированный поток
|
|
|
|
def _subs(self) -> dict:
|
|
return self.cfg.get("subscribers", {}) or {}
|
|
|
|
def check(self, peer_id: int, dmr) -> tuple[bool, str]:
|
|
src = int(getattr(dmr, "src", 0) or 0)
|
|
|
|
if not (ID_MIN <= src <= ID_MAX):
|
|
self.dropped["некорректный ID"] += 1
|
|
return False, "некорректный ID абонента"
|
|
|
|
# Абонент не должен выдавать себя за сам хотспот: обычно это петля или подмена.
|
|
if src == int(peer_id):
|
|
self.dropped["src совпал с хотспотом"] += 1
|
|
return False, "src совпал с номером хотспота"
|
|
|
|
mode = str(self._subs().get("mode", "off")).lower()
|
|
if mode not in ("allow", "deny"):
|
|
return True, ""
|
|
|
|
ids = parse_ids(self._subs().get("list"))
|
|
if mode == "allow" and src not in ids:
|
|
self.dropped["нет в белом списке"] += 1
|
|
self._note(src, dmr, "нет в белом списке")
|
|
return False, f"абонент {src} не в белом списке"
|
|
if mode == "deny" and src in ids:
|
|
self.dropped["в чёрном списке"] += 1
|
|
self._note(src, dmr, "в чёрном списке")
|
|
return False, f"абонент {src} в чёрном списке"
|
|
return True, ""
|
|
|
|
def _note(self, src: int, dmr, why: str) -> None:
|
|
"""В журнал панели — раз на передачу, а не на каждый из ~17 кадров в секунду."""
|
|
sid = getattr(dmr, "stream_id", 0)
|
|
if self._last_note.get(src) == sid:
|
|
return
|
|
self._last_note[src] = sid
|
|
self.state.event("warn", f"эфир запрещён: абонент {src} — {why}")
|