You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
447 lines
18 KiB
447 lines
18 KiB
# ADN DMR Peer Server - alias loader
|
|
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
|
#
|
|
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
|
|
###############################################################################
|
|
# Copyright (C) 2020 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
|
|
# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS <n0mjs@me.com>
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software Foundation,
|
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
###############################################################################
|
|
|
|
"""Load alias dicts (peer_ids, subscriber_ids, talkgroup_ids, etc.). Legacy mk_aliases."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import shutil
|
|
import ssl
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.request import urlopen
|
|
|
|
from ...application.ports import AliasLoader
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def try_download(
|
|
path: Path,
|
|
file_name: str,
|
|
url: str,
|
|
stale_sec: float,
|
|
*,
|
|
max_attempts: int = 3,
|
|
first_timeout: float = 30,
|
|
retry_timeout: float = 10,
|
|
retry_delay_sec: float = 3,
|
|
) -> str:
|
|
"""Legacy try_download: download file from url if missing or older than stale_sec.
|
|
|
|
Retries up to `max_attempts` times on network failure (timeout, connection refused,
|
|
DNS failure, etc). The first attempt uses `first_timeout`; retries use the shorter
|
|
`retry_timeout` so a fully unreachable host can't stall a caller for `max_attempts *
|
|
first_timeout` (this runs synchronously during startup, and off-thread on the
|
|
periodic reload — see alias_reload_loop in peer_server.py). Returns a result message.
|
|
"""
|
|
if not url:
|
|
return f"ID ALIAS MAPPER: '{file_name}' URL empty, not downloaded"
|
|
full = path / file_name
|
|
now = time.time()
|
|
file_exists = full.is_file()
|
|
if file_exists:
|
|
file_old = (full.stat().st_mtime + stale_sec) < now
|
|
else:
|
|
file_old = True
|
|
if not file_old and file_exists:
|
|
return f"ID ALIAS MAPPER: '{file_name}' is current, not downloaded"
|
|
|
|
data: bytes | None = None
|
|
last_error: OSError | None = None
|
|
for attempt in range(1, max_attempts + 1):
|
|
timeout = first_timeout if attempt == 1 else retry_timeout
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
with urlopen(url, context=ctx, timeout=timeout) as response:
|
|
data = response.read()
|
|
last_error = None
|
|
break
|
|
except OSError as e:
|
|
last_error = e
|
|
if attempt < max_attempts:
|
|
logger.warning(
|
|
"(ALIAS) ID ALIAS MAPPER: '%s' download attempt %d/%d failed (%s), retrying in %gs",
|
|
file_name, attempt, max_attempts, e, retry_delay_sec,
|
|
)
|
|
if retry_delay_sec:
|
|
time.sleep(retry_delay_sec)
|
|
if last_error is not None:
|
|
return f"ID ALIAS MAPPER: '{file_name}' could not be downloaded due to an IOError: {last_error}"
|
|
if not data or data == b"{}":
|
|
return f"ID ALIAS MAPPER: '{file_name}' file not written because downloaded data is empty"
|
|
try:
|
|
full.parent.mkdir(parents=True, exist_ok=True)
|
|
full.write_bytes(data)
|
|
except OSError as e:
|
|
return f"ID ALIAS mapper '{file_name}' file could not be written: {e}"
|
|
return f"ID ALIAS MAPPER: '{file_name}' successfully downloaded"
|
|
|
|
|
|
_DOWNLOAD_FAILURE_MARKERS = (
|
|
"could not be downloaded",
|
|
"could not be written",
|
|
"file not written because",
|
|
)
|
|
|
|
|
|
def _log_download_result(result: str) -> None:
|
|
"""Log a try_download result at a level a monitoring/alerting pipeline can filter on."""
|
|
if any(marker in result for marker in _DOWNLOAD_FAILURE_MARKERS):
|
|
logger.error("(ALIAS) %s", result)
|
|
else:
|
|
logger.info("(ALIAS) %s", result)
|
|
|
|
|
|
def _blake2bsum(file_path: Path) -> str:
|
|
"""Blake2b hex digest of file (legacy blake2bsum)."""
|
|
h = hashlib.blake2b()
|
|
with open(file_path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(4096), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
class DefaultAliasLoader(AliasLoader):
|
|
"""Load aliases from JSON files and optional downloads. Legacy mk_aliases."""
|
|
|
|
def load_aliases(
|
|
self,
|
|
config: dict[str, Any],
|
|
) -> tuple[
|
|
dict[int, str],
|
|
dict[int, str],
|
|
dict[int, str],
|
|
dict[int, str],
|
|
dict[str, str],
|
|
dict[str, str],
|
|
]:
|
|
"""Build alias dicts. Same order as legacy mk_aliases."""
|
|
aliases = config.get("ALIASES", {})
|
|
path = Path(aliases.get("PATH", "./data/")).resolve()
|
|
stale_sec = float(aliases.get("STALE_TIME", aliases.get("STALE_DAYS", 1) * 86400))
|
|
if aliases.get("TRY_DOWNLOAD"):
|
|
if aliases.get("CHECKSUM_FILE") and aliases.get("CHECKSUM_URL"):
|
|
result = try_download(path, aliases["CHECKSUM_FILE"], aliases.get("CHECKSUM_URL", ""), stale_sec)
|
|
_log_download_result(result)
|
|
for key, url_key in [
|
|
("PEER_FILE", "PEER_URL"),
|
|
("SUBSCRIBER_FILE", "SUBSCRIBER_URL"),
|
|
("TGID_FILE", "TGID_URL"),
|
|
("SERVER_ID_FILE", "SERVER_ID_URL"),
|
|
]:
|
|
url = aliases.get(url_key)
|
|
if url and aliases.get(key):
|
|
result = try_download(path, aliases[key], url, stale_sec)
|
|
_log_download_result(result)
|
|
checksums = self._load_checksums(path, aliases.get("CHECKSUM_FILE"))
|
|
peer_file = aliases.get("PEER_FILE", "peer_ids.json")
|
|
sub_file = aliases.get("SUBSCRIBER_FILE", "subscriber_ids.json")
|
|
tgid_file = aliases.get("TGID_FILE", "talkgroup_ids.json")
|
|
server_file = aliases.get("SERVER_ID_FILE", "server_ids.tsv")
|
|
peer_ids = self._load_id_dict_with_backup(
|
|
path, peer_file, checksums.get("peer_ids"), "peer_ids",
|
|
)
|
|
subscriber_ids = self._load_id_dict_with_backup(
|
|
path, sub_file, checksums.get("subscriber_ids"), "subscriber_ids",
|
|
)
|
|
talkgroup_ids = self._load_id_dict_with_backup(
|
|
path, tgid_file, checksums.get("talkgroup_ids"), "talkgroup_ids",
|
|
)
|
|
local_subscriber_ids = self._load_id_json(
|
|
path / aliases.get("LOCAL_SUBSCRIBER_FILE", "subscriber_ids.json")
|
|
)
|
|
server_ids = self._load_server_tsv_with_backup(
|
|
path, server_file, checksums.get("server_ids"),
|
|
)
|
|
return (peer_ids, subscriber_ids, talkgroup_ids, local_subscriber_ids, server_ids, checksums)
|
|
|
|
@staticmethod
|
|
def merge_reload_into_config(
|
|
config: dict[str, Any],
|
|
alias_loader: AliasLoader,
|
|
peer_ids: dict[int, str],
|
|
subscriber_ids: dict[int, str],
|
|
talkgroup_ids: dict[int, str],
|
|
local_subscriber_ids: dict[int, str],
|
|
server_ids: dict[str, str],
|
|
checksums: dict[str, str],
|
|
) -> None:
|
|
"""Apply alias reload without wiping in-memory tables on partial download failure."""
|
|
def _keep(key: str, new_val: dict, label: str) -> None:
|
|
if new_val:
|
|
config[key] = new_val
|
|
elif config.get(key):
|
|
logger.warning(
|
|
"(ALIAS) reload kept previous %s (%d entries)",
|
|
label,
|
|
len(config[key]),
|
|
)
|
|
|
|
_keep("_PEER_IDS", peer_ids, "peer_ids")
|
|
if subscriber_ids:
|
|
sub = dict(subscriber_ids)
|
|
sub[900999] = "D-APRS"
|
|
sub[4294967295] = "SC"
|
|
config["_SUB_IDS"] = sub
|
|
if isinstance(alias_loader, DefaultAliasLoader):
|
|
config["_SUB_PROFILES"] = alias_loader.load_subscriber_profiles(config)
|
|
elif config.get("_SUB_IDS"):
|
|
logger.warning(
|
|
"(ALIAS) reload kept previous subscriber_ids (%d entries)",
|
|
len(config["_SUB_IDS"]),
|
|
)
|
|
_keep("_TG_IDS", talkgroup_ids, "talkgroup_ids")
|
|
_keep("_LOCAL_SUBSCRIBER_IDS", local_subscriber_ids, "local_subscriber_ids")
|
|
_keep("_SERVER_IDS", server_ids, "server_ids")
|
|
if checksums:
|
|
config["CHECKSUMS"] = checksums
|
|
|
|
def _load_checksums(self, path: Path, file_name: str | None) -> dict[str, str]:
|
|
"""Load checksum JSON (legacy load_json of CHECKSUM_FILE). Keys e.g. peer_ids, subscriber_ids, talkgroup_ids, server_ids."""
|
|
if not file_name:
|
|
return {}
|
|
full = path / file_name
|
|
if not full.is_file():
|
|
return {}
|
|
try:
|
|
with open(full, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.error("(ALIAS) ID ALIAS MAPPER: Cannot load checksums: %s", e)
|
|
return {}
|
|
return dict(data) if isinstance(data, dict) else {}
|
|
|
|
def _load_server_tsv(self, path: Path, file_name: str) -> dict[str, str]:
|
|
"""Legacy mk_server_dict: TSV with 'OPB Net ID' -> 'Country'."""
|
|
full = path / file_name
|
|
if not full.is_file():
|
|
return {}
|
|
try:
|
|
with open(full, "r", newline="", encoding="utf-8") as f:
|
|
reader = csv.DictReader(f, dialect="excel-tab")
|
|
out: dict[str, str] = {}
|
|
for row in reader:
|
|
net_id = row.get("OPB Net ID", "").strip()
|
|
country = row.get("Country", "").strip()
|
|
if net_id:
|
|
out[net_id] = country
|
|
return out
|
|
except Exception as err:
|
|
logger.warning("(ALIAS) ID ALIAS MAPPER: %s could not be read: %s", file_name, err)
|
|
return {}
|
|
|
|
def _load_id_dict_with_backup(
|
|
self,
|
|
path: Path,
|
|
file_name: str,
|
|
expected_checksum: str | None,
|
|
name: str,
|
|
) -> dict[int, str]:
|
|
"""Legacy mk_aliases peer/subscriber/tgid load with .bak fallback."""
|
|
full = path / file_name
|
|
bak = path / f"{file_name}.bak"
|
|
result: dict[int, str] = {}
|
|
loaded_from_primary = False
|
|
|
|
def _load_verified(target: Path) -> dict[int, str]:
|
|
if not target.is_file():
|
|
# Raise (not return {}) so the caller falls back to .bak below instead of
|
|
# silently ending up with an empty dictionary when the primary file is
|
|
# simply missing (e.g. first boot with the download still failing).
|
|
raise FileNotFoundError(f"'{target.name}' file does not exist")
|
|
if expected_checksum:
|
|
if _blake2bsum(target) != expected_checksum:
|
|
raise ValueError("bad checksum")
|
|
loaded = self._load_id_json(target)
|
|
if not loaded:
|
|
raise ValueError("empty or invalid dictionary data")
|
|
return loaded
|
|
|
|
try:
|
|
result = _load_verified(full)
|
|
loaded_from_primary = True
|
|
except Exception as e:
|
|
logger.error(
|
|
"(ALIAS) ID ALIAS MAPPER: problem loading %s file (%s), falling back to .bak",
|
|
name,
|
|
e,
|
|
)
|
|
if bak.is_file():
|
|
try:
|
|
result = self._load_id_json(bak)
|
|
except Exception as f:
|
|
logger.error(
|
|
"(ALIAS) ID ALIAS MAPPER: Tried backup %s file, but couldn't load that either: %s",
|
|
name,
|
|
f,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"(ALIAS) ID ALIAS MAPPER: no .bak available for %s, dictionary will be empty",
|
|
name,
|
|
)
|
|
if result:
|
|
logger.info("(ALIAS) ID ALIAS MAPPER: %s dictionary is available", name)
|
|
else:
|
|
logger.warning("(ALIAS) ID ALIAS MAPPER: %s dictionary is empty", name)
|
|
if loaded_from_primary and full.is_file():
|
|
try:
|
|
shutil.copy(full, bak)
|
|
except OSError as g:
|
|
logger.info(
|
|
"(ALIAS) ID ALIAS MAPPER: couldn't make backup copy of %s file %s",
|
|
name,
|
|
g,
|
|
)
|
|
return result
|
|
|
|
def _load_id_json(self, file_path: Path) -> dict[int, str]:
|
|
"""Load JSON with 'id' -> 'callsign' structure; return {int(id): callsign}."""
|
|
if not file_path.is_file():
|
|
return {}
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
return {}
|
|
if not isinstance(data, dict) or "count" in data:
|
|
if isinstance(data, dict) and "count" in data:
|
|
del data["count"]
|
|
out: dict[int, str] = {}
|
|
if isinstance(data, dict):
|
|
for key, val in data.items():
|
|
if isinstance(val, list):
|
|
for record in val:
|
|
if isinstance(record, dict) and "id" in record and "callsign" in record:
|
|
try:
|
|
out[int(record["id"])] = str(record["callsign"])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return out
|
|
|
|
def _load_server_tsv_with_backup(
|
|
self,
|
|
path: Path,
|
|
file_name: str,
|
|
expected_checksum: str | None,
|
|
) -> dict[str, str]:
|
|
"""Legacy mk_aliases server_ids load with .bak fallback."""
|
|
full = path / file_name
|
|
bak = path / f"{file_name}.bak"
|
|
result: dict[str, str] = {}
|
|
loaded_from_primary = False
|
|
|
|
try:
|
|
# Raise (not just skip) on a missing primary file too, so the except block
|
|
# below falls back to .bak instead of silently ending up with an empty dict
|
|
# (e.g. first boot with the download still failing).
|
|
if not full.is_file():
|
|
raise FileNotFoundError(f"'{file_name}' file does not exist")
|
|
if expected_checksum and _blake2bsum(full) != expected_checksum:
|
|
raise ValueError("bad checksum")
|
|
result = self._load_server_tsv(path, file_name)
|
|
if not result:
|
|
raise ValueError("empty server_ids")
|
|
loaded_from_primary = True
|
|
except Exception as e:
|
|
logger.error(
|
|
"(ALIAS) ID ALIAS MAPPER: problem loading server_ids file (%s), falling back to .bak",
|
|
e,
|
|
)
|
|
if bak.is_file():
|
|
try:
|
|
result = self._load_server_tsv(path, f"{file_name}.bak")
|
|
except Exception as f:
|
|
logger.error(
|
|
"(ALIAS) ID ALIAS MAPPER: Tried backup server_ids file, but couldn't load that either: %s",
|
|
f,
|
|
)
|
|
if result:
|
|
logger.info("(ALIAS) ID ALIAS MAPPER: server_ids dictionary is available")
|
|
else:
|
|
logger.warning("(ALIAS) ID ALIAS MAPPER: server_ids dictionary is empty")
|
|
if loaded_from_primary and full.is_file():
|
|
try:
|
|
shutil.copy(full, bak)
|
|
except OSError as g:
|
|
logger.info(
|
|
"(ALIAS) ID ALIAS MAPPER: couldn't make backup copy of server_ids file %s",
|
|
g,
|
|
)
|
|
return result
|
|
|
|
def load_subscriber_profiles(self, config: dict[str, Any]) -> dict[int, dict[str, str]]:
|
|
"""Load {id: {callsign, fname, surname, talker_alias?}} from subscriber JSON files."""
|
|
aliases = config.get("ALIASES", {})
|
|
path = Path(aliases.get("PATH", "./data/")).resolve()
|
|
sub_file = aliases.get("SUBSCRIBER_FILE", "subscriber_ids.json")
|
|
local_file = aliases.get("LOCAL_SUBSCRIBER_FILE", "subscriber_ids.json")
|
|
profiles: dict[int, dict[str, str]] = {}
|
|
for file_name in (sub_file, local_file):
|
|
self._merge_subscriber_profiles(path / file_name, profiles)
|
|
return profiles
|
|
|
|
def _merge_subscriber_profiles(self, file_path: Path, out: dict[int, dict[str, str]]) -> None:
|
|
if not file_path.is_file():
|
|
return
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
return
|
|
if not isinstance(data, dict):
|
|
return
|
|
if "count" in data:
|
|
data = {k: v for k, v in data.items() if k != "count"}
|
|
for _key, val in data.items():
|
|
if not isinstance(val, list):
|
|
continue
|
|
for record in val:
|
|
if not isinstance(record, dict) or "id" not in record:
|
|
continue
|
|
try:
|
|
rid = int(record["id"])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
entry: dict[str, str] = {}
|
|
if record.get("callsign"):
|
|
entry["callsign"] = str(record["callsign"])
|
|
if record.get("fname"):
|
|
entry["fname"] = str(record["fname"])
|
|
if record.get("surname"):
|
|
entry["surname"] = str(record["surname"])
|
|
if record.get("talker_alias"):
|
|
entry["talker_alias"] = str(record["talker_alias"])
|
|
if entry:
|
|
prev = out.get(rid, {})
|
|
prev.update(entry)
|
|
out[rid] = prev
|