панель: многостраничная, 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
co-authored by Claude Opus 4.8
родитель 47cb85327a
Коммит cb4e8a9bb1
9 изменённых файлов: 1045 добавлений и 125 удалений
+111
Просмотреть файл
@@ -0,0 +1,111 @@
"""Проверка доступа: пароли хотспотов и списки абонентов.
Это защитная логика, поэтому проверяется не только «разрешает кого надо», но и
«НЕ разрешает кого не надо» — вторая половина важнее и обычно её забывают.
Запуск: python3 tests/test_access.py
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from dmrsrv.access import Guard, make_password_for, parse_ids # noqa: E402
ok_n = fail_n = 0
def ok(m):
global ok_n
ok_n += 1
print(f" \033[32mOK\033[0m {m}")
def bad(m):
global fail_n
fail_n += 1
print(f" \033[31mFAIL\033[0m {m}")
def check(cond, good, bad_msg):
ok(good) if cond else bad(bad_msg)
class FakeState:
def __init__(self):
self.events = []
def event(self, kind, text):
self.events.append((kind, text))
class Frame:
def __init__(self, src, dst=9, stream_id=1):
self.src, self.dst, self.stream_id = src, dst, stream_id
self.call_type = "group"
print("==> разбор списков ID")
check(parse_ids([1, "2", "5-7"]) == {1, 2, 5, 6, 7}, "числа и диапазоны разбираются",
f"разобрано неверно: {parse_ids([1, '2', '5-7'])}")
check(parse_ids(["7-5"]) == {5, 6, 7}, "перевёрнутый диапазон нормализуется", "7-5 не разобран")
check(parse_ids(["мусор", "", None]) == set(), "мусор пропускается без падения", "мусор не отфильтрован")
check(parse_ids(["0-16777215"]) == set(), "слишком широкий диапазон отвергнут (защита от «пустить всех»)",
"широкий диапазон принят — так можно случайно открыть всё")
print("==> пароли хотспотов")
st = FakeState()
cfg = {"passphrase": "obshiy", "allow_unknown_hotspots": True,
"hotspots": [{"peer_id": 100, "password": "lichniy", "enabled": True},
{"peer_id": 200, "password": "", "enabled": True},
{"peer_id": 300, "password": "x", "enabled": False}]}
pf = make_password_for(cfg, st)
check(pf(100) == b"lichniy", "у хотспота свой пароль", f"получено {pf(100)!r}")
check(pf(200) == b"obshiy", "пустой пароль = общий", f"получено {pf(200)!r}")
check(pf(300) is None, "отключённый хотспот не пускается", "отключённый хотспот пустили!")
check(pf(999) == b"obshiy", "незнакомый пускается по общему паролю (режим по умолчанию)",
f"получено {pf(999)!r}")
cfg["allow_unknown_hotspots"] = False
check(pf(999) is None, "режим «только из списка»: незнакомый не пускается",
"незарегистрированный хотспот пустили при закрытом режиме!")
check(pf(100) == b"lichniy", "в закрытом режиме свои продолжают работать", "свой хотспот перестал пускаться")
print("==> списки абонентов")
st = FakeState()
cfg = {"subscribers": {"mode": "off", "list": []}}
g = Guard(cfg, st)
check(g.check(100, Frame(2501001))[0], "режим off — пускает всех", "off не пускает")
cfg["subscribers"] = {"mode": "allow", "list": ["2501001", "2502000-2502010"]}
check(g.check(100, Frame(2501001))[0], "белый список: свой проходит", "свой не прошёл")
check(g.check(100, Frame(2502005))[0], "белый список: диапазон работает", "диапазон не сработал")
check(not g.check(100, Frame(2509999))[0], "белый список: чужой НЕ проходит",
"чужой прошёл через белый список!")
cfg["subscribers"] = {"mode": "deny", "list": ["2501001"]}
check(not g.check(100, Frame(2501001))[0], "чёрный список: запрещённый НЕ проходит",
"запрещённый абонент прошёл!")
check(g.check(100, Frame(2501002))[0], "чёрный список: остальные проходят", "лишнего заблокировали")
print("==> некорректные и подозрительные ID")
cfg["subscribers"] = {"mode": "off", "list": []}
check(not g.check(100, Frame(0))[0], "нулевой ID отвергается", "нулевой ID пропущен")
check(not g.check(100, Frame(99999999))[0], "ID за пределами 24 бит отвергается", "слишком большой ID пропущен")
check(not g.check(100, Frame(100))[0], "src, равный номеру хотспота, отвергается (петля/подмена)",
"src == peer_id пропущен")
print("==> журнал для пользователя")
st2 = FakeState()
cfg2 = {"subscribers": {"mode": "deny", "list": ["777"]}}
g2 = Guard(cfg2, st2)
for _ in range(20):
g2.check(100, Frame(777, stream_id=42)) # одна передача = один кадр за другим
g2.check(100, Frame(777, stream_id=43)) # следующая передача
check(len(st2.events) == 2, "запись в журнал раз на передачу, а не на каждый кадр",
f"записей {len(st2.events)}, ожидалось 2 — журнал захлебнётся при 17 кадрах в секунду")
print(f"\n \033[1m{ok_n} OK, {fail_n} FAIL\033[0m\n")
sys.exit(0 if fail_n == 0 else 1)
+196
Просмотреть файл
@@ -0,0 +1,196 @@
"""Проверка панели: страницы, API, SSE и Last Heard.
Поднимает настоящий сервер и говорит с ним по HTTP, как браузер.
Запуск: python3 tests/test_panel.py
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from base64 import b64encode
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
CFG = "/tmp/dmrsrv-panel-test.json"
PORT = 18080
UDP = 18031
ok_n = fail_n = 0
def ok(m):
global ok_n
ok_n += 1
print(f" \033[32mOK\033[0m {m}")
def bad(m):
global fail_n
fail_n += 1
print(f" \033[31mFAIL\033[0m {m}")
def check(c, g, b):
ok(g) if c else bad(b)
Path(CFG).write_text(json.dumps({
"bind": "127.0.0.1", "port": UDP, "passphrase": "testpass",
"panel": {"bind": "127.0.0.1", "port": PORT, "user": "admin", "password": "sekret"},
"groups": [{"tg": 9, "name": "Локальная", "slot": 2}],
"parrot": {"enabled": False, "tg": 9990},
"hotspots": [], "allow_unknown_hotspots": True,
"subscribers": {"mode": "off", "list": []},
}, ensure_ascii=False))
env = dict(os.environ, DMRSRV_CONFIG=CFG)
srv = subprocess.Popen([sys.executable, "-m", "dmrsrv"], cwd=ROOT, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
AUTH = "Basic " + b64encode(b"admin:sekret").decode()
def req(path, data=None, auth=True, timeout=6):
r = urllib.request.Request(f"http://127.0.0.1:{PORT}{path}")
if auth:
r.add_header("Authorization", AUTH)
if data is not None:
r.data = json.dumps(data).encode()
r.add_header("Content-Type", "application/json")
return urllib.request.urlopen(r, timeout=timeout)
try:
for _ in range(60):
try:
req("/api/state").read()
break
except Exception:
time.sleep(0.25)
else:
print("сервер не поднялся:", srv.stdout.read() if srv.stdout else "")
sys.exit(1)
print("==> авторизация")
try:
req("/", auth=False)
bad("панель отдаётся без пароля!")
except urllib.error.HTTPError as e:
check(e.code == 401, "без пароля — 401", f"код {e.code}")
check(req("/").status == 200, "с паролем — 200", "с паролем не пускает")
print("==> страницы")
html = req("/").read().decode()
for pid, name in [("p-overview", "Обзор"), ("p-lastheard", "Last Heard"),
("p-hotspots", "Хотспоты"), ("p-groups", "Группы"),
("p-access", "Доступ"), ("p-settings", "Настройки")]:
check(f'id={pid}' in html, f"страница «{name}» есть", f"нет страницы {pid}")
check("EventSource" in html, "журнал подключён живым потоком (SSE)", "SSE не подключён")
print("==> управление хотспотами")
req("/api/hotspots/add", {"peer_id": 2501001, "name": "Гараж", "password": "hotpass"})
st = json.load(req("/api/state"))
check(any(h["peer_id"] == 2501001 for h in st["hotspots"]), "хотспот добавлен", "не добавился")
try:
req("/api/hotspots/add", {"peer_id": 2501001})
bad("дубль хотспота принят")
except urllib.error.HTTPError as e:
check(e.code == 400, "повторный номер отклонён", f"код {e.code}")
req("/api/hotspots/toggle", {"peer_id": 2501001})
st = json.load(req("/api/state"))
check(st["hotspots"][0]["enabled"] is False, "хотспот отключается", "не отключился")
print("==> списки абонентов")
req("/api/subscribers", {"mode": "deny", "list": "2501111 2502000-2502005"})
st = json.load(req("/api/state"))
check(st["subscribers"]["mode"] == "deny", "режим сохранён", "режим не сохранён")
check(len(st["subscribers"]["list"]) == 2, "список сохранён", "список не сохранён")
try:
req("/api/subscribers", {"mode": "allow", "list": "не-число"})
bad("мусор в списке принят — оператор не увидит опечатку")
except urllib.error.HTTPError as e:
check(e.code == 400, "мусор в списке отклонён с объяснением", f"код {e.code}")
try:
req("/api/subscribers", {"mode": "странно", "list": ""})
bad("неизвестный режим принят")
except urllib.error.HTTPError as e:
check(e.code == 400, "неизвестный режим отклонён", f"код {e.code}")
print("==> Last Heard наполняется из эфира")
# шлём настоящее рукопожатие и голосовой вызов
import hashlib
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
PEER = 2509999
def rt(p):
s.sendto(p, ("127.0.0.1", UDP))
return s.recv(2048)
salt = rt(b"RPTL" + PEER.to_bytes(4, "big"))[6:10]
rt(b"RPTK" + PEER.to_bytes(4, "big") + hashlib.sha256(salt + b"testpass").digest())
body = (f"{'LHTEST':<8.8s}{438000000:09d}{430000000:09d}{5:02d}{1:02d}"
f"{'51.66600':8.8s}{'-1.170000':9.9s}{10:03d}{'Irkutsk':<20.20s}"
f"{'Russia':<19.19s}4{'https://x':<124.124s}{'MMDVM_v1':<40.40s}{'MMDVM_HS':<40.40s}")
rt(b"RPTC" + PEER.to_bytes(4, "big") + body.encode())
def frame(seq, dtype, ftype=0b10):
bits = 0x80 | (ftype << 4) | dtype
return (b"DMRD" + bytes([seq]) + (2501234).to_bytes(3, "big") + (9).to_bytes(3, "big")
+ PEER.to_bytes(4, "big") + bytes([bits]) + b"\xaa\xbb\xcc\xdd"
+ bytes(range(33)) + b"\x00\x00")
s.sendto(frame(0, 0x01), ("127.0.0.1", UDP)) # начало
for i in range(1, 6):
s.sendto(frame(i, i % 6, 0b00), ("127.0.0.1", UDP))
s.sendto(frame(6, 0x02), ("127.0.0.1", UDP)) # терминатор
time.sleep(1.5)
st = json.load(req("/api/state"))
calls = st.get("calls", [])
check(len(calls) >= 1, "вызов попал в Last Heard", "Last Heard пуст после реального вызова")
if calls:
c = calls[0]
check(c["src_id"] == 2501234, "источник записан верно", f"src={c['src_id']}")
check(c["dst_id"] == 9, "назначение записано верно", f"dst={c['dst_id']}")
check(c["peer_id"] == PEER, "видно, через какой хотспот", f"peer={c['peer_id']}")
check(not c["active"], "вызов закрыт по терминатору", "вызов остался активным")
check(c["frames"] >= 6, "кадры посчитаны", f"кадров {c['frames']}")
print("==> поля RPTC доходят до панели")
p = st["peers"][0] if st["peers"] else {}
check(p.get("country") == "Russia", "страна (Pi-Star кладёт её в description)",
f"country={p.get('country')!r}")
check(p.get("location") == "Irkutsk", "местоположение", f"location={p.get('location')!r}")
check(p.get("color_code") == 1, "color code (раньше читался как colorcode и был пуст)",
f"color_code={p.get('color_code')!r}")
check(p.get("tx_power") == 5, "мощность", f"tx_power={p.get('tx_power')!r}")
check(p.get("rx_freq") == 438000000, "частота RX", f"rx={p.get('rx_freq')!r}")
check(p.get("lat") == 51.666, "широта", f"lat={p.get('lat')!r}")
check(p.get("package_id") == "MMDVM_HS", "модель устройства", f"pkg={p.get('package_id')!r}")
print("==> живой поток событий (SSE)")
r = urllib.request.Request(f"http://127.0.0.1:{PORT}/api/events")
r.add_header("Authorization", AUTH)
resp = urllib.request.urlopen(r, timeout=6)
check(resp.headers.get("Content-Type", "").startswith("text/event-stream"),
"заголовок text/event-stream", f"тип {resp.headers.get('Content-Type')}")
line = resp.readline().decode()
check(line.startswith("data: "), "события приходят в формате SSE", f"строка {line!r}")
resp.close()
finally:
srv.terminate()
try:
srv.wait(timeout=5)
except subprocess.TimeoutExpired:
srv.kill()
print(f"\n \033[1m{ok_n} OK, {fail_n} FAIL\033[0m\n")
sys.exit(0 if fail_n == 0 else 1)