Files
dmr-server/dmrsrv/panel.py
T
viktorиClaude Opus 4.8 8e3b9144c3 DMR-сервер: мастер Homebrew на stdlib для Pi-Star
Ядро протокола форкнуто из dmrhubv2 (~1300 строк из 31700): analyzer, server,
parrot, dmr_voice, bptc. Отрезаны портал, БД, мосты, алерты, динамические
подписки. Своё: конфиг, состояние, маршрутизатор, панель, установщик.

Ноль зависимостей — на Debian 13 действует PEP 668, pip в систему запрещён.

Две правки под Pi-Star, которых в исходном коде не было:
 • peer_id переписывается на получателя (байты 11-14) через хук encode_for —
   так делают HBlink3/HBlink4 и BrandMeister, а DMRGateway на Pi-Star стоит
   между MMDVMHost и сетью;
 • приватный вызов при неизвестном абоненте уходит всем, а не в никуда
   (взято из HBlink4: user cache with broadcast fallback).

docs/PROTOCOL.md фиксирует, что тест-вектор auth-хеша в спецификации 2015 года
ОШИБОЧЕН (посчитан от ASCII-строки соли вместо четырёх байт). Правка кода по
нему сломала бы вход всем хотспотам.

tests/test_e2e.py: 15 проверок настоящими UDP-кадрами MMDVMHost. Все проходят.

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

256 строки
13 KiB
Python

"""Веб-панель: хотспоты, группы, живой журнал, настройки.
Работает на stdlib `http.server` в отдельном потоке — так проще и надёжнее, чем
писать разбор HTTP поверх asyncio. Нагрузка тут никакая: несколько хотспотов и
одна-две открытые вкладки.
Авторизация — HTTP Basic. Для внутренней сети этого достаточно, а формы с
сессиями потребовали бы куки, CSRF и хранение сессий ради того же результата.
"""
from __future__ import annotations
import hmac
import json
import threading
from base64 import b64decode
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from . import config as cfgmod
from .log import get_logger
log = get_logger(__name__)
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["port"] = cfg.get("port")
return self._json(snap)
self.send_error(404)
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/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 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
PAGE = """<!doctype html><html lang=ru><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>DMR-сервер</title>
<style>
:root{--bg:#0f1115;--fg:#e6e6e6;--dim:#8b93a1;--line:#242833;--acc:#4da3ff;--ok:#3ddc84;--warn:#ffb454;--err:#ff5f56}
@media(prefers-color-scheme:light){:root{--bg:#fff;--fg:#15181d;--dim:#5c6472;--line:#e3e6ec;--acc:#0b63ce}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}
h1{font-size:16px;margin:0;font-weight:650}
.dim{color:var(--dim)}
main{padding:18px;display:grid;gap:18px;max-width:1200px}
section{border:1px solid var(--line);border-radius:10px;overflow:hidden}
h2{font-size:13px;margin:0;padding:10px 14px;background:rgba(127,127,127,.07);border-bottom:1px solid var(--line);font-weight:600}
.body{padding:12px 14px}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:7px 14px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}
th{color:var(--dim);font-weight:500;font-size:12px}
tr:last-child td{border-bottom:none}
.scroll{overflow-x:auto}
#log{max-height:340px;overflow-y:auto;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;padding:10px 14px}
#log div{white-space:pre-wrap;word-break:break-word}
.k-warn{color:var(--warn)}.k-error{color:var(--err)}.k-call{color:var(--ok)}
button{background:var(--acc);color:#fff;border:0;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px}
button.sec{background:transparent;color:var(--dim);border:1px solid var(--line)}
input{background:transparent;color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
form{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.empty{color:var(--dim);padding:14px}
</style>
<header>
<h1>DMR-сервер</h1>
<span class=dim id=head>подключение…</span>
</header>
<main>
<section><h2>Хотспоты</h2><div class=scroll><table id=peers></table></div></section>
<section><h2>Группы</h2>
<div class=scroll><table id=groups></table></div>
<div class=body><form id=addg>
<input name=tg placeholder="TG" size=7 required inputmode=numeric>
<input name=name placeholder="название" size=18>
<input name=slot placeholder="слот" value=2 size=4 inputmode=numeric>
<button>Добавить</button>
</form></div>
</section>
<section><h2>Последние вызовы</h2><div class=scroll><table id=calls></table></div></section>
<section><h2>Журнал</h2><div id=log></div></section>
</main>
<script>
const $=s=>document.querySelector(s);
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const hhmm=t=>new Date(t*1000).toLocaleTimeString('ru-RU');
function dur(s){const m=Math.floor(s/60),h=Math.floor(m/60);
return h?`${h} ч ${m%60} мин`:m?`${m} мин ${s%60} с`:`${s} с`;}
function tbl(el,head,rows,empty){
if(!rows.length){el.outerHTML=`<div class=empty id=${el.id}>${empty}</div>`;return;}
el.outerHTML=`<table id=${el.id}><tr>${head.map(h=>`<th>${h}</th>`).join('')}</tr>${rows.join('')}</table>`;
}
async function post(url,body){
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));
if(!r.ok){alert(j.error||'ошибка');return false}
return true;
}
$('#addg').onsubmit=async e=>{e.preventDefault();
const f=new FormData(e.target);
if(await post('/api/groups/add',{tg:f.get('tg'),name:f.get('name'),slot:f.get('slot')})){e.target.reset();e.target.slot.value=2;tick();}};
async function delg(tg,slot){if(await post('/api/groups/del',{tg,slot}))tick();}
async function kick(id){if(confirm('Отключить хотспот '+id+'?')&&await post('/api/peer/kick',{peer_id:id}))tick();}
async function tick(){
let d; try{d=await(await fetch('/api/state')).json()}catch(e){$('#head').textContent='нет связи с сервером';return}
$('#head').innerHTML=`порт ${d.port} · хотспотов ${d.peers.length} · работает ${dur(d.uptime)}`;
tbl($('#peers'),['ID','позывной','адрес','описание','статус','простой','принято'],
d.peers.map(p=>`<tr><td>${p.peer_id}</td><td>${esc(p.callsign)}</td><td>${esc(p.addr)}</td>
<td>${esc(p.description||p.location||'')}</td><td>${esc(p.stage)}</td><td>${p.idle} с</td>
<td>${p.rx_packets}</td><td><button class=sec onclick="kick(${p.peer_id})">отключить</button></td></tr>`),
'Ни один хотспот не подключён');
tbl($('#groups'),['TG','название','слот',''],
d.groups.map(g=>`<tr><td>${g.tg}</td><td>${esc(g.name||'')}</td><td>TS${g.slot}</td>
<td><button class=sec onclick="delg(${g.tg},${g.slot})">удалить</button></td></tr>`),
'Групп нет — добавь хотя бы одну, иначе групповые вызовы никуда не пойдут');
tbl($('#calls'),['время','откуда','куда','тип','слот','длит.','кадров'],
d.calls.slice(0,15).map(c=>`<tr><td>${hhmm(c.started)}</td><td>${c.src_id}</td><td>${c.dst_id}</td>
<td>${c.type}</td><td>TS${c.slot}</td><td>${c.duration} с</td><td>${c.frames}</td></tr>`),
'Вызовов ещё не было');
$('#log').innerHTML=d.log.slice(-200).map(e=>
`<div class="k-${e.kind}"><span class=dim>${hhmm(e.t)}</span> ${esc(e.text)}</div>`).join('');
}
tick(); setInterval(tick,2000);
</script>
</html>"""