панель: многостраничная, 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
Этот коммит содержится в:
+121
-92
@@ -12,16 +12,26 @@ from __future__ import annotations
|
||||
import hmac
|
||||
import json
|
||||
import threading
|
||||
import queue as _queue
|
||||
from base64 import b64decode
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import config as cfgmod
|
||||
from .page import PAGE
|
||||
from .log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def _valid_id_token(s: str) -> bool:
|
||||
"""Число или диапазон «нач-кон». Проверяем в панели, чтобы человек сразу
|
||||
увидел опечатку, а не гадал, почему абонента не пускает."""
|
||||
s = s.strip()
|
||||
parts = s.split("-", 1) if "-" in s else [s]
|
||||
return all(p.strip().isdigit() and 1 <= int(p) <= 16777215 for p in parts) and len(parts) <= 2
|
||||
|
||||
|
||||
def _make_handler(ctx):
|
||||
cfg, state = ctx["cfg"], ctx["state"]
|
||||
drop_peer = ctx["drop_peer"]
|
||||
@@ -79,10 +89,52 @@ def _make_handler(ctx):
|
||||
snap = state.snapshot()
|
||||
snap["groups"] = cfg.get("groups", [])
|
||||
snap["parrot"] = cfg.get("parrot", {})
|
||||
snap["hotspots"] = cfg.get("hotspots", [])
|
||||
snap["subscribers"] = cfg.get("subscribers", {"mode": "off", "list": []})
|
||||
snap["allow_unknown"] = cfg.get("allow_unknown_hotspots", True)
|
||||
snap["port"] = cfg.get("port")
|
||||
return self._json(snap)
|
||||
if path == "/api/events":
|
||||
return self._sse()
|
||||
self.send_error(404)
|
||||
|
||||
def _sse(self) -> None:
|
||||
"""Живой поток событий (Server-Sent Events).
|
||||
|
||||
Заменяет опрос раз в две секунды: события уходят в браузер сразу,
|
||||
как только случились. Каждое соединение обслуживается своим потоком
|
||||
(ThreadingHTTPServer), очередь ограничена — зависший браузер не
|
||||
затормозит приём эфира.
|
||||
"""
|
||||
q = state.subscribe()
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.send_header("X-Accel-Buffering", "no") # чтобы не буферизовал прокси
|
||||
self.end_headers()
|
||||
# Сразу отдаём хвост журнала, иначе после перезагрузки страницы пусто.
|
||||
for rec in state.snapshot()["log"][-100:]:
|
||||
self._sse_send(rec)
|
||||
while True:
|
||||
try:
|
||||
rec = q.get(timeout=15)
|
||||
except _queue.Empty:
|
||||
self.wfile.write(b": keepalive\n\n") # держим соединение живым
|
||||
self.wfile.flush()
|
||||
continue
|
||||
self._sse_send(rec)
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass # вкладку закрыли — это норма
|
||||
finally:
|
||||
state.unsubscribe(q)
|
||||
|
||||
def _sse_send(self, rec) -> None:
|
||||
self.wfile.write(b"data: " + json.dumps(rec, ensure_ascii=False).encode("utf-8")
|
||||
+ b"\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not self._authed():
|
||||
return self._deny()
|
||||
@@ -121,6 +173,71 @@ def _make_handler(ctx):
|
||||
state.event("info", f"панель: удалена группа TG{tg} (слот {slot})")
|
||||
return self._json({"ok": True, "groups": cfg["groups"]})
|
||||
|
||||
if path == "/api/hotspots/add":
|
||||
try:
|
||||
pid = int(data["peer_id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return self._json({"error": "нужен peer_id (номер хотспота)"}, 400)
|
||||
if not (1 <= pid <= 0xFFFFFFFF):
|
||||
return self._json({"error": "peer_id вне допустимого диапазона"}, 400)
|
||||
if any(int(h["peer_id"]) == pid for h in cfg["hotspots"]):
|
||||
return self._json({"error": f"хотспот {pid} уже в списке"}, 400)
|
||||
pw = str(data.get("password", ""))
|
||||
if pw and len(pw) < 4:
|
||||
return self._json({"error": "пароль слишком короткий"}, 400)
|
||||
cfg["hotspots"].append({"peer_id": pid, "password": pw,
|
||||
"name": str(data.get("name", ""))[:40], "enabled": True})
|
||||
cfgmod.save(cfg)
|
||||
state.event("info", f"панель: добавлен хотспот {pid}"
|
||||
+ (" (свой пароль)" if pw else " (общий пароль)"))
|
||||
return self._json({"ok": True, "hotspots": cfg["hotspots"]})
|
||||
|
||||
if path == "/api/hotspots/del":
|
||||
try:
|
||||
pid = int(data["peer_id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return self._json({"error": "нужен peer_id"}, 400)
|
||||
before = len(cfg["hotspots"])
|
||||
cfg["hotspots"] = [h for h in cfg["hotspots"] if int(h["peer_id"]) != pid]
|
||||
if len(cfg["hotspots"]) == before:
|
||||
return self._json({"error": "такого хотспота нет в списке"}, 404)
|
||||
cfgmod.save(cfg)
|
||||
state.event("info", f"панель: хотспот {pid} удалён из списка")
|
||||
return self._json({"ok": True, "hotspots": cfg["hotspots"]})
|
||||
|
||||
if path == "/api/hotspots/toggle":
|
||||
try:
|
||||
pid = int(data["peer_id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return self._json({"error": "нужен peer_id"}, 400)
|
||||
for h in cfg["hotspots"]:
|
||||
if int(h["peer_id"]) == pid:
|
||||
h["enabled"] = not h.get("enabled", True)
|
||||
cfgmod.save(cfg)
|
||||
st = "включён" if h["enabled"] else "отключён"
|
||||
state.event("warn", f"панель: хотспот {pid} {st}")
|
||||
# отключили — рвём текущую сессию, иначе он останется в эфире
|
||||
if not h["enabled"]:
|
||||
drop_peer(pid)
|
||||
return self._json({"ok": True, "enabled": h["enabled"]})
|
||||
return self._json({"error": "такого хотспота нет"}, 404)
|
||||
|
||||
if path == "/api/subscribers":
|
||||
mode = str(data.get("mode", "off")).lower()
|
||||
if mode not in ("off", "allow", "deny"):
|
||||
return self._json({"error": "режим: off, allow или deny"}, 400)
|
||||
raw = data.get("list", [])
|
||||
if isinstance(raw, str):
|
||||
raw = [x for x in raw.replace(",", " ").split() if x]
|
||||
bad = [x for x in raw if not _valid_id_token(str(x))]
|
||||
if bad:
|
||||
return self._json({"error": "непонятные записи: " + ", ".join(map(str, bad[:5]))}, 400)
|
||||
cfg["subscribers"] = {"mode": mode, "list": [str(x) for x in raw]}
|
||||
cfgmod.save(cfg)
|
||||
names = {"off": "выключены", "allow": "белый список", "deny": "чёрный список"}
|
||||
state.event("warn", f"панель: списки абонентов — {names[mode]}, записей: {len(raw)}")
|
||||
return self._json({"ok": True, "subscribers": cfg["subscribers"]})
|
||||
|
||||
if path == "/api/settings":
|
||||
changed = []
|
||||
if "passphrase" in data:
|
||||
@@ -132,6 +249,10 @@ def _make_handler(ctx):
|
||||
if "parrot_enabled" in data:
|
||||
cfg["parrot"]["enabled"] = bool(data["parrot_enabled"])
|
||||
changed.append("попугай " + ("включён" if cfg["parrot"]["enabled"] else "выключен"))
|
||||
if "allow_unknown_hotspots" in data:
|
||||
cfg["allow_unknown_hotspots"] = bool(data["allow_unknown_hotspots"])
|
||||
changed.append("незнакомые хотспоты: "
|
||||
+ ("пускать" if cfg["allow_unknown_hotspots"] else "только из списка"))
|
||||
if not changed:
|
||||
return self._json({"error": "нечего менять"}, 400)
|
||||
cfgmod.save(cfg)
|
||||
@@ -161,95 +282,3 @@ def start(cfg: dict, state, drop_peer) -> ThreadingHTTPServer:
|
||||
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>"""
|
||||
|
||||
Ссылка в новой задаче
Block a user