parent
cc82de74fc
commit
d778af3545
@ -0,0 +1,56 @@
|
||||
modules = ["python-3.12", "bash", "web"]
|
||||
|
||||
[workflows]
|
||||
runButton = "Project"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Project"
|
||||
mode = "parallel"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "FreeDMR Server"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "Password Dashboard"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "FreeDMR Server"
|
||||
author = "agent"
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
outputType = "console"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "python bridge_master.py -c ./config/adn.cfg"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Password Dashboard"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "python dashboard.py"
|
||||
waitForPort = 5000
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
outputType = "webview"
|
||||
|
||||
[[ports]]
|
||||
localPort = 4321
|
||||
externalPort = 3000
|
||||
|
||||
[[ports]]
|
||||
localPort = 5000
|
||||
externalPort = 80
|
||||
|
||||
[agent]
|
||||
expertMode = true
|
||||
integrations = ["github:1.0.0"]
|
||||
|
||||
[nix]
|
||||
channel = "stable-25_05"
|
||||
packages = ["cargo", "gitFull", "glibcLocales", "libiconv", "libxcrypt", "openssl", "pkg-config", "rustc", "wkhtmltopdf"]
|
||||
|
After Width: | Height: | Size: 22 KiB |
@ -0,0 +1,161 @@
|
||||
[GLOBAL]
|
||||
PATH: ./
|
||||
PING_TIME: 10
|
||||
MAX_MISSED: 3
|
||||
USE_ACL: True
|
||||
REG_ACL: PERMIT:ALL
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
GEN_STAT_BRIDGES: True
|
||||
ANNOUNCEMENT_LANGUAGES:
|
||||
SERVER_ID: 0000
|
||||
DATA_GATEWAY: False
|
||||
VALIDATE_SERVER_IDS: False
|
||||
|
||||
; Servidor de seguridad centralizado
|
||||
URL_SECURITY:
|
||||
PORT_SECURITY:
|
||||
PASS_SECURITY:
|
||||
USERS_PASS: user_passwords.json
|
||||
HASH_ENCRYPT: encryption_key.secret
|
||||
|
||||
[REPORTS]
|
||||
REPORT: True
|
||||
REPORT_INTERVAL: 10
|
||||
REPORT_PORT: 4321
|
||||
REPORT_CLIENTS: 127.0.0.1
|
||||
|
||||
[LOGGER]
|
||||
LOG_FILE: /dev/null
|
||||
LOG_HANDLERS: console-timed
|
||||
LOG_LEVEL: DEBUG
|
||||
LOG_NAME: ADN
|
||||
|
||||
[ALIASES]
|
||||
TRY_DOWNLOAD: True
|
||||
PATH: ./data/
|
||||
PEER_FILE: peer_ids.json
|
||||
SUBSCRIBER_FILE: subscriber_ids.json
|
||||
TGID_FILE: talkgroup_ids.json
|
||||
PEER_URL: https://adn.systems/files/peer_ids.json
|
||||
SUBSCRIBER_URL: https://adn.systems/files/subscriber_ids.json
|
||||
TGID_URL: https://adn.systems/files/talkgroup_ids.json
|
||||
SERVER_ID_URL: https://adn.systems/files/server_ids.tsv
|
||||
CHECKSUM_URL: https://adn.systems/files/file_checksums.json
|
||||
LOCAL_SUBSCRIBER_FILE: subscriber_ids.json
|
||||
STALE_DAYS: 1
|
||||
SUB_MAP_FILE: sub_map.pkl
|
||||
SERVER_ID_FILE: server_ids.tsv
|
||||
CHECKSUM_FILE: file_checksums.json
|
||||
KEYS_FILE: keys.json
|
||||
|
||||
#Control server shared allstar instance via dial / AMI
|
||||
[ALLSTAR]
|
||||
ENABLED: False
|
||||
USER:llcgi
|
||||
PASS: mypass
|
||||
SERVER: my.asl.server
|
||||
PORT: 5038
|
||||
NODE: 0000
|
||||
|
||||
[OBP-TEST]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: False
|
||||
IP:
|
||||
PORT: 62044
|
||||
NETWORK_ID: 1
|
||||
PASSPHRASE: mypass
|
||||
TARGET_IP:
|
||||
TARGET_PORT: 62044
|
||||
USE_ACL: True
|
||||
SUB_ACL: DENY:1
|
||||
TGID_ACL: DENY:0-82,92-199,800-899,9990-9999,900999
|
||||
RELAX_CHECKS: True
|
||||
ENHANCED_OBP: True
|
||||
PROTO_VER: 5
|
||||
|
||||
[SYSTEM]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
REPEAT: True
|
||||
MAX_PEERS: 1
|
||||
EXPORT_AMBE: False
|
||||
IP:
|
||||
PORT: 56400
|
||||
PASSPHRASE: passw0rd
|
||||
GROUP_HANGTIME: 5
|
||||
USE_ACL: True
|
||||
REG_ACL: DENY:1
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
DEFAULT_UA_TIMER: 60
|
||||
SINGLE_MODE: False
|
||||
VOICE_IDENT: False
|
||||
TS1_STATIC:
|
||||
TS2_STATIC:
|
||||
DEFAULT_REFLECTOR: 0
|
||||
ANNOUNCEMENT_LANGUAGE: es_ES
|
||||
GENERATOR: 100
|
||||
ALLOW_UNREG_ID: False
|
||||
PROXY_CONTROL: True
|
||||
OVERRIDE_IDENT_TG:
|
||||
|
||||
|
||||
[ECHO]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
REPEAT: True
|
||||
MAX_PEERS: 1
|
||||
EXPORT_AMBE: False
|
||||
IP: 127.0.0.1
|
||||
PORT: 54917
|
||||
PASSPHRASE: passw0rd
|
||||
GROUP_HANGTIME: 5
|
||||
USE_ACL: True
|
||||
REG_ACL: DENY:1
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: DENY:ALL
|
||||
TGID_TS2_ACL: PERMIT:9990
|
||||
DEFAULT_UA_TIMER: 1
|
||||
SINGLE_MODE: True
|
||||
VOICE_IDENT: False
|
||||
TS1_STATIC:
|
||||
TS2_STATIC:9990
|
||||
DEFAULT_REFLECTOR: 0
|
||||
ANNOUNCEMENT_LANGUAGE: en_GB
|
||||
GENERATOR: 0
|
||||
ALLOW_UNREG_ID: True
|
||||
PROXY_CONTROL: False
|
||||
OVERRIDE_IDENT_TG:
|
||||
|
||||
[D-APRS]
|
||||
MODE: MASTER
|
||||
ENABLED: True
|
||||
REPEAT: False
|
||||
MAX_PEERS: 1
|
||||
EXPORT_AMBE: False
|
||||
IP:
|
||||
PORT: 52555
|
||||
PASSPHRASE: passw0rd
|
||||
GROUP_HANGTIME: 0
|
||||
USE_ACL: True
|
||||
REG_ACL: DENY:1
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
DEFAULT_UA_TIMER: 10
|
||||
SINGLE_MODE: False
|
||||
VOICE_IDENT: False
|
||||
TS1_STATIC:
|
||||
TS2_STATIC:
|
||||
DEFAULT_REFLECTOR: 0
|
||||
ANNOUNCEMENT_LANGUAGE: es_ES
|
||||
GENERATOR: 2
|
||||
ALLOW_UNREG_ID: True
|
||||
PROXY_CONTROL: False
|
||||
OVERRIDE_IDENT_TG:
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1 @@
|
||||
{"subscriber_ids":"e3d7df1cea54b717e670ae310d92aa7fbb5230c18a5e42a5063b38d396562706f12ee487c5e6c61afa71bebbd01db267d68570bc11e877b088e5b5058220e77a","peer_ids":"d223516beafe7b427402f86642477a5d1d1479c88ae7368f1e8ba7f0728a0355271fbe59ba4e85773759e8824015aaa663a117b1d5e704f3886bf9e27e790e66","talkgroup_ids":"143624f8278a654d4d30769ccc18cdc77e135c6f372216cdad35b6fbfcf767043c38e886c150d3ae4757ce8125c49212b362a949de4b6d7e58e0b88f02bba5a1","server_ids":"536c1a52705bad24a3eb5167b1ea2af9f07e08618039c5e1ef49b65f361911b2c968bb58506ffe99798758c60c49b0de8660f2d924a2a66246a5b11dd0062893","timestamp":1767603901}
|
||||
@ -0,0 +1 @@
|
||||
{}
|
||||
|
@ -0,0 +1,76 @@
|
||||
# Servicios Systemd para ADN Systems DMR
|
||||
|
||||
## Archivos de Servicio
|
||||
|
||||
- **adn-bridge.service** - Servidor DMR principal (bridge_master.py)
|
||||
- **adn-dashboard.service** - Panel de administración de contraseñas (dashboard.py)
|
||||
|
||||
## Instalación
|
||||
|
||||
### 1. Copiar el proyecto a /opt/adn-dmr
|
||||
```bash
|
||||
sudo mkdir -p /opt/adn-dmr
|
||||
sudo cp -r /ruta/del/proyecto/* /opt/adn-dmr/
|
||||
```
|
||||
|
||||
### 2. Copiar los servicios a systemd
|
||||
```bash
|
||||
sudo cp adn-bridge.service /etc/systemd/system/
|
||||
sudo cp adn-dashboard.service /etc/systemd/system/
|
||||
```
|
||||
|
||||
### 3. Recargar systemd
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
### 4. Habilitar servicios para arranque automático
|
||||
```bash
|
||||
sudo systemctl enable adn-bridge.service
|
||||
sudo systemctl enable adn-dashboard.service
|
||||
```
|
||||
|
||||
### 5. Iniciar los servicios
|
||||
```bash
|
||||
sudo systemctl start adn-bridge.service
|
||||
sudo systemctl start adn-dashboard.service
|
||||
```
|
||||
|
||||
## Comandos Útiles
|
||||
|
||||
### Ver estado de los servicios
|
||||
```bash
|
||||
sudo systemctl status adn-bridge.service
|
||||
sudo systemctl status adn-dashboard.service
|
||||
```
|
||||
|
||||
### Ver logs en tiempo real
|
||||
```bash
|
||||
sudo journalctl -u adn-bridge.service -f
|
||||
sudo journalctl -u adn-dashboard.service -f
|
||||
```
|
||||
|
||||
### Reiniciar servicios
|
||||
```bash
|
||||
sudo systemctl restart adn-bridge.service
|
||||
sudo systemctl restart adn-dashboard.service
|
||||
```
|
||||
|
||||
### Detener servicios
|
||||
```bash
|
||||
sudo systemctl stop adn-bridge.service
|
||||
sudo systemctl stop adn-dashboard.service
|
||||
```
|
||||
|
||||
### Deshabilitar arranque automático
|
||||
```bash
|
||||
sudo systemctl disable adn-bridge.service
|
||||
sudo systemctl disable adn-dashboard.service
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Los servicios asumen que el proyecto está instalado en `/opt/adn-dmr`
|
||||
- Si usas otra ruta, edita `WorkingDirectory` en los archivos .service
|
||||
- El dashboard escucha en el puerto 5000
|
||||
- Asegúrate de tener las dependencias Python instaladas antes de iniciar
|
||||
@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=ADN Systems DMR Bridge Master - Servidor DMR principal
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/adn-dmr
|
||||
ExecStart=/usr/bin/python3 bridge_master.py -c ./config/adn.cfg
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Script de instalacion para ADN Systems DMR Peer Server
|
||||
# Debian 13 (Trixie) - ARM64/ARMv7
|
||||
#
|
||||
# Este script instala las dependencias necesarias para compilar
|
||||
# y ejecutar el servidor DMR en sistemas ARM con Debian 13
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
echo "=============================================="
|
||||
echo " ADN Systems DMR Peer Server"
|
||||
echo " Instalador para Debian 13 (Trixie) ARM"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: Este script debe ejecutarse como root (sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/4] Actualizando repositorios..."
|
||||
apt-get update
|
||||
|
||||
echo ""
|
||||
echo "[2/4] Instalando dependencias de compilacion..."
|
||||
apt-get install -y \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
git
|
||||
|
||||
echo ""
|
||||
echo "[3/4] Instalando dependencias de Python..."
|
||||
pip3 install --break-system-packages -r requirements.txt
|
||||
|
||||
echo ""
|
||||
echo "[4/4] Verificando instalacion..."
|
||||
python3 -c "import bitarray; import twisted; import flask; print('Todas las dependencias instaladas correctamente')"
|
||||
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo " Instalacion completada exitosamente"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
echo "Para iniciar el servidor:"
|
||||
echo " python3 bridge_master.py -c ./config/adn.cfg"
|
||||
echo ""
|
||||
echo "Para iniciar el dashboard:"
|
||||
echo " python3 dashboard.py"
|
||||
echo ""
|
||||
@ -0,0 +1,48 @@
|
||||
from cryptography.fernet import Fernet
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
ENCRYPTION_KEY_FILE = 'config/encryption_key.secret'
|
||||
|
||||
def get_or_create_key():
|
||||
if os.path.exists(ENCRYPTION_KEY_FILE):
|
||||
with open(ENCRYPTION_KEY_FILE, 'rb') as f:
|
||||
return f.read()
|
||||
else:
|
||||
key = Fernet.generate_key()
|
||||
os.makedirs(os.path.dirname(ENCRYPTION_KEY_FILE), exist_ok=True)
|
||||
with open(ENCRYPTION_KEY_FILE, 'wb') as f:
|
||||
f.write(key)
|
||||
return key
|
||||
|
||||
def get_fernet():
|
||||
key = get_or_create_key()
|
||||
return Fernet(key)
|
||||
|
||||
def encrypt_password(password):
|
||||
if not password:
|
||||
return password
|
||||
fernet = get_fernet()
|
||||
encrypted = fernet.encrypt(password.encode('utf-8'))
|
||||
return encrypted.decode('utf-8')
|
||||
|
||||
def decrypt_password(encrypted_password):
|
||||
if not encrypted_password:
|
||||
return encrypted_password
|
||||
try:
|
||||
fernet = get_fernet()
|
||||
decrypted = fernet.decrypt(encrypted_password.encode('utf-8'))
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception:
|
||||
return encrypted_password
|
||||
|
||||
def is_encrypted(value):
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
fernet = get_fernet()
|
||||
fernet.decrypt(value.encode('utf-8'))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@ -0,0 +1,209 @@
|
||||
# ADN Systems DMR Peer Server
|
||||
|
||||
## Overview
|
||||
|
||||
ADN Systems DMR Peer Server is a fork of FreeDMR, implementing a Digital Mobile Radio (DMR) network server. Launched in April 2024 by international amateur radio enthusiasts, it operates on an Open Bridge Protocol (OBP) fostering a decentralized network architecture. The system handles DMR voice and data communication, acting as a conference bridge/reflector that routes traffic between connected systems (repeaters, hotspots, peers) based on configurable bridge rules.
|
||||
|
||||
## User Preferences
|
||||
|
||||
Preferred communication style: Simple, everyday language.
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Core Protocol Implementation
|
||||
|
||||
**Problem**: Need to implement HomeBrew Repeater Protocol (HBP) and Open Bridge Protocol for DMR communication
|
||||
**Solution**: Python-based protocol handlers using Twisted framework for asynchronous networking
|
||||
- `hblink.py` serves as the core protocol engine implementing HBSYSTEM and OPENBRIDGE classes
|
||||
- Supports both peer and master/server modes for network topology flexibility
|
||||
- Uses DatagramProtocol for UDP-based DMR packet handling
|
||||
- Implements custom authentication using SHA256/BLAKE2b hashing with HMAC for security
|
||||
|
||||
**Rationale**: Twisted provides battle-tested async I/O suitable for handling multiple simultaneous connections with low latency requirements critical for voice communication.
|
||||
|
||||
### Bridge/Routing Architecture
|
||||
|
||||
**Problem**: Route voice traffic between multiple DMR systems based on talkgroups and timeslots
|
||||
**Solution**: Conference bridge pattern implemented in `bridge_master.py` and `bridge.py`
|
||||
- Traffic routing based on rules defined in `rules.py` configuration
|
||||
- Systems can be dynamically activated/deactivated on conference bridges
|
||||
- Supports timeout-based automatic disconnection
|
||||
- Static routing option available for permanent connections
|
||||
|
||||
**Alternatives Considered**: End-to-end routing was rejected in favor of conference bridge approach for better scalability and simpler management.
|
||||
|
||||
**Pros**: Flexible routing, easy management, scalable
|
||||
**Cons**: Systems must individually join bridges (not transparent end-to-end)
|
||||
|
||||
### Proxy Architecture
|
||||
|
||||
**Problem**: Allow multiple hotspots/repeaters to connect through a single public IP address
|
||||
**Solution**: UDP proxy implementation in `hotspot_proxy_v2.py` and `hotspot_proxy_self_service.py`
|
||||
- Dynamic port allocation for incoming connections
|
||||
- Connection tracking with timeout-based cleanup
|
||||
- Blacklist support for access control
|
||||
- Self-service variant includes database-backed client management
|
||||
|
||||
**Rationale**: Many amateur radio operators are behind NAT/firewalls; proxy enables connectivity without port forwarding.
|
||||
|
||||
### Voice Synthesis System
|
||||
|
||||
**Problem**: Generate voice announcements for system events (linking, unlinking, status)
|
||||
**Solution**: AMBE (Advanced Multi-Band Excitation) voice codec integration
|
||||
- Pre-recorded indexed voice files in multiple languages (en_GB, es_ES, fr_FR, etc.)
|
||||
- `read_ambe.py` handles reading/parsing of AMBE audio files
|
||||
- `mk_voice.py` generates HBP-compliant voice packets from AMBE data
|
||||
- `playback.py` and `play_ambe.py` handle voice injection into streams
|
||||
|
||||
**Rationale**: Provides accessible feedback to users without requiring external TTS systems, maintains compatibility with DMR audio codecs.
|
||||
|
||||
### Individual Password Authentication
|
||||
|
||||
**Problem**: Need individual password authentication per Radio ID (indicativo) for enhanced security
|
||||
**Solution**: JSON-based password storage with automatic reload
|
||||
- Password file: `data/user_passwords.json` stores individual passwords by Radio ID
|
||||
- Web dashboard: `dashboard.py` provides admin interface for password management
|
||||
- Auto-reload: Passwords are reloaded every 10 seconds without server restart
|
||||
- Fallback: Global passphrase used if no individual password is configured
|
||||
- Mandatory authentication: Individual or global password required (no null passphrase allowed)
|
||||
|
||||
**Suffix Support for Radio IDs**:
|
||||
- Radio IDs of 9 digits (ej: 214501601-214501699) can use the password of their 7-digit base ID (ej: 2145016)
|
||||
- Useful for users with multiple hotspots/devices using suffix extensions
|
||||
- Priority: Exact ID match first, then base ID match
|
||||
|
||||
**Authentication Priority**:
|
||||
1. Individual password for exact Radio ID (if configured)
|
||||
2. Individual password for base ID (7 digits, for 9-digit IDs with suffix)
|
||||
3. Global passphrase (if configured)
|
||||
4. Reject connection (no valid credentials)
|
||||
|
||||
**Dashboard Credentials**: Stored in `config/dashboard_credentials.json` file (not uploaded to GitHub)
|
||||
- Supports multiple administrators with the following format:
|
||||
```json
|
||||
{
|
||||
"admins": [
|
||||
{"user": "admin", "password": "admin123"},
|
||||
{"user": "operador1", "password": "clave456"}
|
||||
]
|
||||
}
|
||||
```
|
||||
- Also supports legacy single-admin format for backwards compatibility
|
||||
|
||||
**Dashboard Web con Acceso Dual**:
|
||||
- Pagina principal (`/`): Selector entre acceso usuario y administrador
|
||||
- Acceso Usuario (`/user/login`): Login con Radio ID y contrasena para cambiar su propia contrasena
|
||||
- Acceso Administrador (`/admin/login`): Login para gestionar todas las contrasenas
|
||||
- Los usuarios pueden cambiar su contrasena sin necesitar al administrador
|
||||
|
||||
**Busqueda por Indicativo (Callsign)**:
|
||||
- Descarga automatica diaria de la base de datos de RadioID.net (`data/user.csv`)
|
||||
- Formato CSV con campos: RADIO_ID, CALLSIGN, FIRST_NAME, LAST_NAME, CITY, STATE, COUNTRY
|
||||
- Funcion de busqueda en el panel de administracion para encontrar Radio IDs por indicativo
|
||||
- Muestra toda la informacion del registro: nombre, ciudad, estado, pais
|
||||
- Soporta busqueda parcial (ej: "EA4" muestra todos los indicativos que empiezan por EA4)
|
||||
- Limita resultados a 20 para mejor rendimiento
|
||||
- Boton "Usar ID" para autorellenar el formulario de agregar contrasena
|
||||
|
||||
### Servidor de Seguridad Centralizado (Rama: descentralizada)
|
||||
|
||||
**Problema**: Gestionar credenciales de forma centralizada desde un servidor remoto
|
||||
**Solucion**: Descarga automatica de archivos de seguridad desde servidor central
|
||||
|
||||
**Configuracion en adn.cfg seccion [GLOBAL]**:
|
||||
- `URL_SECURITY`: IP o DNS del servidor central de seguridad
|
||||
- `PORT_SECURITY`: Puerto del servidor central
|
||||
- `PASS_SECURITY`: Contrasena de autenticacion del administrador
|
||||
- `USERS_PASS`: Nombre del archivo de contrasenas (default: user_passwords.json)
|
||||
- `HASH_ENCRYPT`: Nombre del archivo de clave de encriptacion (default: encryption_key.secret)
|
||||
|
||||
**Comportamiento de Descarga**:
|
||||
- `encryption_key.secret`: Se descarga SOLO al arrancar el servidor, sobrescribe el existente
|
||||
- `user_passwords.json`: Se descarga cada 5 minutos, compara contenido antes de actualizar
|
||||
|
||||
**Sintaxis de Descarga (curl)**:
|
||||
```
|
||||
curl -L "http://URL_SECURITY:PORT_SECURITY/descargar?pass=PASS_SECURITY&encryption_key.secret"
|
||||
curl -L "http://URL_SECURITY:PORT_SECURITY/descargar?pass=PASS_SECURITY&user_passwords.json"
|
||||
```
|
||||
|
||||
**Seguridad y Tolerancia a Fallos**:
|
||||
- Si la descarga falla, se conservan los archivos locales existentes
|
||||
- No se permite contrasena nula (ALLOW_NULL_PASSPHRASE eliminado)
|
||||
- Autenticacion obligatoria: contrasena individual o global requerida
|
||||
|
||||
**Archivos Descargados**:
|
||||
- `config/encryption_key.secret`: Clave de encriptacion para descifrar contrasenas
|
||||
- `data/user_passwords.json`: Archivo JSON con contrasenas encriptadas por Radio ID
|
||||
|
||||
### Configuration Management
|
||||
|
||||
**Problem**: Complex multi-system configuration with ACLs, bridges, and network parameters
|
||||
**Solution**: INI-based configuration parsed by `config.py`
|
||||
- Centralized configuration in `config/adn.cfg`
|
||||
- ACL (Access Control List) processing for registration and talkgroup filtering
|
||||
- Support for both whitelist and blacklist approaches
|
||||
- Language preferences and multi-language support via `languages.py`
|
||||
|
||||
### Monitoring and Reporting
|
||||
|
||||
**Problem**: Need visibility into system operation, connections, and traffic
|
||||
**Solution**: Network-based reporting protocol
|
||||
- `report_receiver.py` and `report_sql.py` consume events from the core server
|
||||
- Pickle-based serialization for config/bridge state transmission
|
||||
- Opcode-based protocol defined in `reporting_const.py`
|
||||
- SQL integration option for persistent event logging
|
||||
|
||||
**Rationale**: Separates monitoring concerns from core routing logic, enables multiple monitoring tools.
|
||||
|
||||
### Asterisk Integration
|
||||
|
||||
**Problem**: Integration with Asterisk PBX for advanced features (app_rpt)
|
||||
**Solution**: Asterisk Manager Interface (AMI) client in `AMI.py`
|
||||
- Sends RPT (repeater) commands to Asterisk
|
||||
- Uses Twisted LineReceiver for line-based protocol handling
|
||||
- Supports node-specific command routing
|
||||
|
||||
### API Layer
|
||||
|
||||
**Problem**: External systems need programmatic access to server functions
|
||||
**Solution**: XML-RPC and SOAP API implementation
|
||||
- `API.py` implements Spyne-based SOAP service (FD_API)
|
||||
- Peer validation and authentication endpoints
|
||||
- `api_client.py` provides example XML-RPC client
|
||||
- Key-based authentication for peer systems
|
||||
|
||||
## External Dependencies
|
||||
|
||||
### Network Protocols
|
||||
- **Twisted** (>= 16.3.0) - Asynchronous networking framework for all protocol handling
|
||||
- **dmr_utils3** (>= 0.1.19) - DMR protocol utilities for packet encoding/decoding, BPTC, Golay error correction
|
||||
|
||||
### Database Systems
|
||||
- **MySQL/MariaDB** - Via twisted.enterprise.adbapi and MySQLdb
|
||||
- Used by `proxy_db.py` for self-service proxy client management
|
||||
- Stores client registrations, authentication, and connection tracking
|
||||
- Optional for report_sql.py event logging
|
||||
|
||||
### Remote Object Communication
|
||||
- **Pyro5** - Python Remote Objects for inter-process communication
|
||||
- Used by proxy services for distributed architecture
|
||||
- Enables separation of proxy components across processes/hosts
|
||||
|
||||
### Third-Party Services
|
||||
- **radioid.net API** - DMR ID database lookups
|
||||
- `peer_ids.json`, `subscriber_ids.json`, `talkgroup_ids.json` downloaded from external sources
|
||||
- Cached locally with staleness checking via `utils.py::try_download()`
|
||||
- Provides callsign/name resolution for DMR IDs
|
||||
|
||||
### Supporting Libraries
|
||||
- **bitarray/bitstring** - Binary data manipulation for DMR packet construction
|
||||
- **configparser** - INI file parsing for hblink.cfg
|
||||
- **setproctitle** - Process naming for easier system monitoring
|
||||
- **resettabletimer** - Timeout management for connection tracking
|
||||
- **hashlib/hmac** - Cryptographic functions for authentication
|
||||
|
||||
### Language/Voice Assets
|
||||
- Pre-recorded AMBE voice files in Audio/ directory
|
||||
- Multiple language support (en_GB, es_ES, fr_FR, de_DE, dk_DK, it_IT, no_NO, pl_PL, se_SE, pt_PT, cy_GB, el_GR, th_TH, CW)
|
||||
- Voice file indexing via i8n_voice_map.py
|
||||
@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
import socket
|
||||
from time import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DOWNLOAD_INTERVAL_PASSWORDS = 300
|
||||
DOWNLOAD_INTERVAL_ENCRYPTION = None
|
||||
|
||||
_last_passwords_download = 0
|
||||
_last_passwords_size = 0
|
||||
_last_passwords_content = None
|
||||
|
||||
def resolve_hostname(hostname, timeout=10):
|
||||
try:
|
||||
old_timeout = socket.getdefaulttimeout()
|
||||
socket.setdefaulttimeout(timeout)
|
||||
ip = socket.gethostbyname(hostname)
|
||||
socket.setdefaulttimeout(old_timeout)
|
||||
logger.debug('(SECURITY) Resolved %s to %s', hostname, ip)
|
||||
return ip
|
||||
except socket.gaierror as e:
|
||||
logger.error('(SECURITY) DNS resolution failed for %s: %s', hostname, str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error('(SECURITY) Unexpected error resolving %s: %s', hostname, str(e))
|
||||
return None
|
||||
|
||||
def build_download_url(config, filename):
|
||||
url_security = config['GLOBAL'].get('URL_SECURITY', '').strip()
|
||||
port_security = config['GLOBAL'].get('PORT_SECURITY', '').strip()
|
||||
pass_security = config['GLOBAL'].get('PASS_SECURITY', '').strip()
|
||||
|
||||
if not url_security or not port_security or not pass_security:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
socket.inet_aton(url_security)
|
||||
host = url_security
|
||||
except socket.error:
|
||||
host = resolve_hostname(url_security)
|
||||
if not host:
|
||||
logger.error('(SECURITY) Could not resolve hostname: %s', url_security)
|
||||
return None, None
|
||||
|
||||
url = f"http://{host}:{port_security}/descargar?pass={pass_security}&file={filename}"
|
||||
return url, url_security
|
||||
|
||||
def download_file_safely(url, dest_path, timeout=60):
|
||||
try:
|
||||
temp_fd, temp_path = tempfile.mkstemp()
|
||||
os.close(temp_fd)
|
||||
|
||||
try:
|
||||
logger.debug('(SECURITY) Attempting download from: %s', url)
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header('User-Agent', 'ADN-Systems-DMR/1.0')
|
||||
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
content = response.read()
|
||||
|
||||
if len(content) == 0:
|
||||
logger.warning('(SECURITY) Downloaded file is empty, keeping existing: %s', dest_path)
|
||||
os.unlink(temp_path)
|
||||
return False
|
||||
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
shutil.move(temp_path, dest_path)
|
||||
logger.info('(SECURITY) Successfully downloaded: %s (%d bytes)', dest_path, len(content))
|
||||
return True
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.error('(SECURITY) HTTP error downloading %s: %s (Code: %d)', dest_path, str(e), e.code)
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
logger.error('(SECURITY) URL error downloading %s: %s', dest_path, str(e.reason))
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
return False
|
||||
except socket.timeout:
|
||||
logger.error('(SECURITY) Timeout downloading %s', dest_path)
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error('(SECURITY) Unexpected error downloading %s: %s', dest_path, str(e))
|
||||
return False
|
||||
|
||||
def download_encryption_key(config):
|
||||
hash_encrypt = config['GLOBAL'].get('HASH_ENCRYPT', 'encryption_key.secret').strip()
|
||||
dest_path = os.path.join('config', hash_encrypt)
|
||||
|
||||
url, original_host = build_download_url(config, hash_encrypt)
|
||||
if not url:
|
||||
logger.debug('(SECURITY) Security server not configured, skipping encryption key download')
|
||||
return False
|
||||
|
||||
logger.info('(SECURITY) Downloading encryption key from central server...')
|
||||
return download_file_safely(url, dest_path)
|
||||
|
||||
def download_user_passwords(config, force=False):
|
||||
global _last_passwords_download, _last_passwords_size, _last_passwords_content
|
||||
|
||||
current_time = time()
|
||||
if not force and (current_time - _last_passwords_download) < DOWNLOAD_INTERVAL_PASSWORDS:
|
||||
return False
|
||||
|
||||
users_pass = config['GLOBAL'].get('USERS_PASS', 'user_passwords.json').strip()
|
||||
dest_path = os.path.join('data', users_pass)
|
||||
|
||||
url, original_host = build_download_url(config, users_pass)
|
||||
if not url:
|
||||
logger.debug('(SECURITY) Security server not configured, skipping passwords download')
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.debug('(SECURITY) Downloading passwords from: %s', url)
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header('User-Agent', 'ADN-Systems-DMR/1.0')
|
||||
|
||||
with urllib.request.urlopen(req, timeout=60) as response:
|
||||
new_content = response.read()
|
||||
new_size = len(new_content)
|
||||
|
||||
if new_size == 0:
|
||||
logger.warning('(SECURITY) Downloaded passwords file is empty, keeping existing')
|
||||
_last_passwords_download = current_time
|
||||
return False
|
||||
|
||||
if _last_passwords_content is not None:
|
||||
if new_content == _last_passwords_content:
|
||||
logger.debug('(SECURITY) Passwords file unchanged, no update needed')
|
||||
_last_passwords_download = current_time
|
||||
return False
|
||||
|
||||
temp_fd, temp_path = tempfile.mkstemp()
|
||||
os.close(temp_fd)
|
||||
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(new_content)
|
||||
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
shutil.move(temp_path, dest_path)
|
||||
|
||||
_last_passwords_content = new_content
|
||||
_last_passwords_size = new_size
|
||||
_last_passwords_download = current_time
|
||||
|
||||
logger.info('(SECURITY) Successfully updated passwords file: %s (%d bytes)', dest_path, new_size)
|
||||
return True
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.error('(SECURITY) HTTP error downloading passwords: %s (Code: %d)', str(e), e.code)
|
||||
_last_passwords_download = current_time
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
logger.error('(SECURITY) URL error downloading passwords: %s', str(e.reason))
|
||||
_last_passwords_download = current_time
|
||||
return False
|
||||
except socket.timeout:
|
||||
logger.error('(SECURITY) Timeout downloading passwords')
|
||||
_last_passwords_download = current_time
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error('(SECURITY) Unexpected error downloading passwords: %s', str(e))
|
||||
_last_passwords_download = current_time
|
||||
return False
|
||||
|
||||
def init_security_downloads(config):
|
||||
url_security = config['GLOBAL'].get('URL_SECURITY', '').strip()
|
||||
if not url_security:
|
||||
logger.info('(SECURITY) Central security server not configured')
|
||||
return False
|
||||
|
||||
port_security = config['GLOBAL'].get('PORT_SECURITY', '').strip()
|
||||
|
||||
logger.info('(SECURITY) Initializing centralized security downloads...')
|
||||
logger.info('(SECURITY) Security server: %s:%s', url_security, port_security)
|
||||
|
||||
try:
|
||||
socket.inet_aton(url_security)
|
||||
logger.info('(SECURITY) Using IP address: %s', url_security)
|
||||
except socket.error:
|
||||
resolved_ip = resolve_hostname(url_security)
|
||||
if resolved_ip:
|
||||
logger.info('(SECURITY) Resolved hostname %s to IP: %s', url_security, resolved_ip)
|
||||
else:
|
||||
logger.error('(SECURITY) Failed to resolve hostname: %s', url_security)
|
||||
return False
|
||||
|
||||
download_encryption_key(config)
|
||||
|
||||
download_user_passwords(config, force=True)
|
||||
|
||||
return True
|
||||
|
||||
def periodic_password_download(config):
|
||||
download_user_passwords(config)
|
||||
Loading…
Reference in new issue