Ядро протокола форкнуто из 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
256 строки
13 KiB
Python
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=>({'&':'&','<':'<','>':'>','"':'"'}[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>"""
|