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
co-authored by Claude Opus 4.8
Коммит 8e3b9144c3
17 изменённых файлов: 2680 добавлений и 0 удалений
+90
Просмотреть файл
@@ -0,0 +1,90 @@
"""Конфигурация сервера: JSON-файл, правится и руками, и из панели.
Намеренно без внешних зависимостей — на Debian 13 (PEP 668) любой pip install
в систему запрещён, а заводить venv ради пары библиотек не хочется.
"""
from __future__ import annotations
import contextlib
import json
import os
import secrets
import tempfile
from pathlib import Path
from typing import Any
CONFIG_PATH = Path(os.environ.get("DMRSRV_CONFIG", "/etc/dmr-server/config.json"))
PARROT_TG = 9990 # общепринятый номер «попугая» в DMR-сетях
DEFAULTS: dict[str, Any] = {
"bind": "0.0.0.0",
"port": 62031, # стандартный порт Homebrew/MMDVM
"passphrase": "passw0rd", # пароль хотспотов (RPTK), меняется в панели
"panel": {
"bind": "0.0.0.0",
"port": 8080,
"user": "admin",
"password": "", # пустой -> сгенерируется при первом запуске
},
"groups": [
{"tg": 9, "name": "Локальная", "slot": 2},
{"tg": 1, "name": "Общая", "slot": 1},
],
"parrot": {"enabled": True, "tg": PARROT_TG, "delay": 2.0},
# Хотспоты, которым разрешено подключаться. Пустой список = пускать любой,
# кто знает пароль (удобно на старте, в панели можно перевести в белый список).
"peers_allowlist": [],
"log_keep": 500, # сколько последних событий держать для панели
}
def _merge(base: dict, over: dict) -> dict:
"""Рекурсивно накладывает сохранённое поверх умолчаний.
Нужно, чтобы добавление нового ключа в DEFAULTS не ломало существующий
конфиг и не требовало миграций.
"""
out = dict(base)
for k, v in over.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = _merge(out[k], v)
else:
out[k] = v
return out
def load() -> dict[str, Any]:
cfg = dict(DEFAULTS)
if CONFIG_PATH.exists():
try:
cfg = _merge(DEFAULTS, json.loads(CONFIG_PATH.read_text("utf-8")))
except (json.JSONDecodeError, OSError) as e:
# Битый конфиг не должен ронять сервер: работаем на умолчаниях,
# но громко об этом сообщаем.
print(f"[config] не читается {CONFIG_PATH}: {e}; беру умолчания")
if not cfg["panel"]["password"]:
cfg["panel"]["password"] = secrets.token_urlsafe(12)
save(cfg)
print(f"[config] пароль панели сгенерирован: {cfg['panel']['password']}")
return cfg
def save(cfg: dict[str, Any]) -> None:
"""Атомарная запись: временный файл рядом + rename.
Без этого обрыв питания посреди сохранения из панели оставит битый JSON.
"""
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=CONFIG_PATH.parent, prefix=".config.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, CONFIG_PATH)
os.chmod(CONFIG_PATH, 0o600) # внутри пароли
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise