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
Этот коммит содержится в:
@@ -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
|
||||
Ссылка в новой задаче
Block a user