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