# ADN DMR Peer Server - alias loader # Copyright (C) 2026 Rodrigo Pérez, CE5RPY # # Derived from ADN DMR Server / FreeDMR / HBlink. Original license: ############################################################################### # Copyright (C) 2020 Simon Adlem, G7RZU # Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS # # 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