Files
dmr-server/dmrsrv/access.py
T
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

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}")