Files
Cort BuffingtonandClaude Opus 5 3fa1b32f16 Add XLX reflector support
An XLX system is a MODE: OUTBOUND system with an XLX_MODULE letter set. XLX's
DMR interface is the homebrew protocol unmodified -- DMRGateway uses the same
CDMRNetwork class for XLX and BrandMeister, and there are no XLX conditionals in
its network layer -- so this adds no new mode, subclass, or data path. The only
protocol difference is one in-band private call at connect that selects the
module, plus pinning the connection to TS2/TG9.

Module binding is static by design. Runtime or end-user module selection would
break the nailed-up systems/bridges model; DMRGateway is the right tool for
user-selectable modules at a repeater or hotspot.

TS2/TG9 is injected by expand_xlx_bridges rather than read from rules.py, so
XLX_BRIDGES is a flat {system: bridge} map with no TS or TGID column. This is
not a default -- it makes a wrong value inexpressible. That matters more here
than elsewhere because every XLX failure is silent: the reflector never
acknowledges a link, and no frame on the wire carries a module identity, so a
mistyped talkgroup would leave HBlink looking healthy while traffic vanished.

The existing conference-bridge retarget path already rewrites the header dst,
flips the slot bit, and regenerates the full and embedded LC, so no XLX-specific
rewrite code is needed -- an XLX system is just a bridge member with a fixed
TS/TGID.

An XLX system in UNIT is a startup error. Reflector module selection *is* a
private call to 4001-4026, so forwarding a unit call to one could silently move
the reflector into a different room for every user connected to it.

Behaviour verified against xlxd source (src/cdmrmmdvmprotocol.cpp):

  * Five acceptance gates at :636-655, each of which silently drops the packet:
    exactly 55 bytes, DATA_SYNC frame type, TS2, slot type VOICE_LC_HEADER, and
    a valid BS/MS data sync. The 55-byte requirement is why the packet carries
    two trailing pad bytes; a bare 53-byte DMRD is rejected.
  * Only the three header frames are parsed for the link command; the two
    terminators have slot type 2 and are ignored, but are sent to frame the call.
  * Unlink-then-link is required, not defensive. At :297-325 a link naming a
    different module while already linked is discarded and rewritten to the
    module already held, so a reconnect that skipped the unlink would silently
    stay in the previous room.
  * xlxd reads the destination from DMRD header bytes 8-10 and never decodes the
    LC (its BPTC decode is commented out at :657-660). The LC is still built
    honestly from the same src/dst: a packet whose payload contradicts its header
    is a debugging trap for anything that does decode it.

Note that a stock xlxd is built with NB_OF_MODULES = 10, so only modules A-J
exist unless the operator rebuilt it. A-Z is accepted here since the protocol
allows it, but an absent module produces no link and no error.

tests/test_xlx_link.py adds 35 tests: the acceptance gates transcribed from
xlxd's parser, packet structure, XLX_BRIDGES expansion and its guards, and
XLX_MODULE validation. One test reproduces the canned bursts from the 2019
reference implementation byte for byte, from their own decoded src/dst and
colour code, which pins the burst builder against six years of field-proven
behaviour.

No new dependencies: bitarray and dmr_utils3 were already required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:14:30 -05:00

341 lines
17 KiB
Python
Executable File

#!/usr/bin/env python
#
###############################################################################
# Copyright (C) 2016-2018 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
###############################################################################
'''
This module generates the configuration data structure for hblink.py and
assoicated programs that use it. It has been seaparated into a different
module so as to keep hblink.py easeier to navigate. This file only needs
updated if the items in the main configuraiton file (usually hblink.cfg)
change.
IPv6 support contributed by Dan Srebnick, K2IE <k2ie@k2ie.net>
'''
import configparser
import sys
import const
from socket import getaddrinfo
# Does anybody read this stuff? There's a PEP somewhere that says I should do this.
__author__ = 'Cortney T. Buffington, N0MJS'
__copyright__ = 'Copyright (c) 2016-2018 Cortney T. Buffington, N0MJS and the K0USY Group'
__credits__ = 'Colin Durbridge, G4EML, Steve Zingman, N4IRS; Mike Zingman, N4IRR; Jonathan Naylor, G4KLX; Hans Barthen, DL5DI; Torsten Shultze, DG1HT'
__license__ = 'GNU GPLv3'
__maintainer__ = 'Cort Buffington, N0MJS'
__email__ = 'n0mjs@me.com'
# Processing of ALS goes here. It's separated from the acl_build function because this
# code is hblink config-file format specific, and acl_build is abstracted
def process_acls(_config):
# Global registration ACL
_config['GLOBAL']['REG_ACL'] = acl_build(_config['GLOBAL']['REG_ACL'], const.PEER_MAX)
# Global subscriber and TGID ACLs
for acl in ['SUB_ACL', 'TG1_ACL', 'TG2_ACL']:
_config['GLOBAL'][acl] = acl_build(_config['GLOBAL'][acl], const.ID_MAX)
# System level ACLs
for system in _config['SYSTEMS']:
# Registration ACLs (which make no sense for outbound systems)
if _config['SYSTEMS'][system]['MODE'] == 'SERVER':
_config['SYSTEMS'][system]['REG_ACL'] = acl_build(_config['SYSTEMS'][system]['REG_ACL'], const.PEER_MAX)
# Subscriber and TGID ACLs (valid for all system types)
for acl in ['SUB_ACL', 'TG1_ACL', 'TG2_ACL']:
_config['SYSTEMS'][system][acl] = acl_build(_config['SYSTEMS'][system][acl], const.ID_MAX)
# Merge a list of (start, end) ranges into a minimal sorted list of disjoint
# ranges. Required so acl_check can locate a match with a single bisect.
def merge_ranges(_ranges):
merged = []
for start, end in sorted(_ranges):
if merged and start <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
# Create an access control list that is programatically usable from human readable.
# Single IDs go in a set (O(1) membership); ranges are merged and split into
# parallel sorted (starts, ends) tuples so acl_check can bisect them (O(log n)).
# ORIGINAL: 'DENY:1-5,3120101,3120124'
# PROCESSED: (False, frozenset({3120101, 3120124}), (1,), (5,))
def acl_build(_acl, _max):
if not _acl:
return (True, frozenset(), (const.ID_MIN,), (_max,))
singles = set()
ranges = []
sections = _acl.split(':')
action = (sections[0] == 'PERMIT')
for entry in sections[1].split(','):
if entry == 'ALL':
ranges = [(const.ID_MIN, _max)]
singles = set()
break
elif '-' in entry:
start,end = entry.split('-')
start,end = int(start), int(end)
if (const.ID_MIN <= start <= _max) or (const.ID_MIN <= end <= _max):
ranges.append((start, end))
else:
sys.exit('ACL CREATION ERROR, VALUE OUT OF RANGE ({} - {})IN RANGE-BASED ENTRY: {}'.format(const.ID_MIN, _max, entry))
else:
id = int(entry)
if (const.ID_MIN <= id <= _max):
singles.add(id)
else:
sys.exit('ACL CREATION ERROR, VALUE OUT OF RANGE ({} - {}) IN SINGLE ID ENTRY: {}'.format(const.ID_MIN, _max, entry))
merged = merge_ranges(ranges)
starts = tuple(r[0] for r in merged)
ends = tuple(r[1] for r in merged)
return (action, frozenset(singles), starts, ends)
# Read the optional XLX_MODULE field for an OUTBOUND system. Presence of this
# field is what marks a system as an XLX reflector connection -- there is no
# separate mode or boolean, because XLX's DMR interface *is* the homebrew protocol
# and needs no special handling beyond one link packet at connect.
#
# Returns the module letter as an uppercase str, or '' if the system is not XLX.
# Note that a stock xlxd is built with NB_OF_MODULES = 10 (modules A-J); asking for
# a module the reflector does not have produces no link and no error message of any
# kind, so the value is validated here for form only.
def _parse_xlx_module(_config, _section):
if not _config.has_option(_section, 'XLX_MODULE'):
return ''
_module = _config.get(_section, 'XLX_MODULE').strip().upper()
if not _module:
return ''
if len(_module) != 1 or not ('A' <= _module <= 'Z'):
sys.exit('XLX CONFIGURATION ERROR: XLX_MODULE for system "{}" must be a single '
'letter A-Z, got "{}". Module numbers (4001-4026) are not accepted -- '
'the talkgroup is derived from the letter.'.format(_section, _module))
return _module
def build_config(_config_file):
config = configparser.ConfigParser()
if not config.read(_config_file):
sys.exit('Configuration file \''+_config_file+'\' is not a valid configuration file! Exiting...')
CONFIG = {}
CONFIG['GLOBAL'] = {}
CONFIG['REPORTS'] = {}
CONFIG['LOGGER'] = {}
CONFIG['ALIASES'] = {}
CONFIG['SYSTEMS'] = {}
try:
for section in config.sections():
if section == 'GLOBAL':
CONFIG['GLOBAL'].update({
'PATH': config.get(section, 'PATH'),
'PING_TIME': config.getint(section, 'PING_TIME'),
'MAX_MISSED': config.getint(section, 'MAX_MISSED'),
# Chronic connectivity: report a repeater's ping loss over the
# last PING_LOSS_WINDOW minutes (clamped 1..60), and flag it on
# the dashboard at/above PING_LOSS_WARN percent. Optional; sane
# defaults keep existing configs working.
'PING_LOSS_WINDOW': min(60, max(1, config.getint(section, 'PING_LOSS_WINDOW', fallback=5))),
'PING_LOSS_WARN': max(0, config.getint(section, 'PING_LOSS_WARN', fallback=5)),
'USE_ACL': config.get(section, 'USE_ACL'),
'REG_ACL': config.get(section, 'REG_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
'TG1_ACL': config.get(section, 'TGID_TS1_ACL'),
'TG2_ACL': config.get(section, 'TGID_TS2_ACL')
})
elif section == 'REPORTS':
CONFIG['REPORTS'].update({
'REPORT': config.getboolean(section, 'REPORT'),
'REPORT_INTERVAL': config.getint(section, 'REPORT_INTERVAL'),
'REPORT_PORT': config.getint(section, 'REPORT_PORT'),
'REPORT_CLIENTS': config.get(section, 'REPORT_CLIENTS').split(','),
# Reporting transport: 'tcp' (default, dashboard may be remote)
# or 'unix' (same-box dashboard via REPORT_SOCKET path). Optional.
'REPORT_TRANSPORT': config.get(section, 'REPORT_TRANSPORT', fallback='tcp').strip().lower(),
'REPORT_SOCKET': config.get(section, 'REPORT_SOCKET', fallback='').strip(),
})
elif section == 'LOGGER':
CONFIG['LOGGER'].update({
'LOG_FILE': config.get(section, 'LOG_FILE'),
'LOG_HANDLERS': config.get(section, 'LOG_HANDLERS'),
'LOG_LEVEL': config.get(section, 'LOG_LEVEL'),
'LOG_NAME': config.get(section, 'LOG_NAME')
})
if not CONFIG['LOGGER']['LOG_FILE']:
CONFIG['LOGGER']['LOG_FILE'] = '/dev/null'
elif section == 'ALIASES':
CONFIG['ALIASES'].update({
'USE_ALIASES': config.getboolean(section, 'USE_ALIASES', fallback=False),
'TRY_DOWNLOAD': config.getboolean(section, 'TRY_DOWNLOAD'),
'PATH': config.get(section, 'PATH'),
'PEER_FILE': config.get(section, 'PEER_FILE'),
'SUBSCRIBER_FILE': config.get(section, 'SUBSCRIBER_FILE'),
'TGID_FILE': config.get(section, 'TGID_FILE'),
'PEER_URL': config.get(section, 'PEER_URL'),
'SUBSCRIBER_URL': config.get(section, 'SUBSCRIBER_URL'),
'STALE_TIME': config.getint(section, 'STALE_DAYS') * 86400,
})
elif config.getboolean(section, 'ENABLED'):
if config.get(section, 'MODE') == 'OUTBOUND':
CONFIG['SYSTEMS'].update({section: {
'MODE': config.get(section, 'MODE'),
'ENABLED': config.getboolean(section, 'ENABLED'),
'LOOSE': config.getboolean(section, 'LOOSE'),
'IP': getaddrinfo(config.get(section, 'IP'), 0)[0][4][0],
'PORT': config.getint(section, 'PORT'),
'SERVER_SOCKADDR': getaddrinfo(config.get(section, 'SERVER_IP'), config.getint(section, 'SERVER_PORT'), proto=17)[0][4],
'SERVER_IP': getaddrinfo(config.get(section, 'SERVER_IP'), 0)[0][4][0],
'SERVER_PORT': config.getint(section, 'SERVER_PORT'),
'PASSPHRASE': bytes(config.get(section, 'PASSPHRASE'), 'utf-8'),
'CALLSIGN': bytes(config.get(section, 'CALLSIGN').ljust(8)[:8], 'utf-8'),
'RADIO_ID': config.getint(section, 'RADIO_ID').to_bytes(4, 'big'),
'RX_FREQ': bytes(config.get(section, 'RX_FREQ').ljust(9)[:9], 'utf-8'),
'TX_FREQ': bytes(config.get(section, 'TX_FREQ').ljust(9)[:9], 'utf-8'),
'TX_POWER': bytes(config.get(section, 'TX_POWER').rjust(2,'0'), 'utf-8'),
'COLORCODE': bytes(config.get(section, 'COLORCODE').rjust(2,'0'), 'utf-8'),
'LATITUDE': bytes(config.get(section, 'LATITUDE').ljust(8)[:8], 'utf-8'),
'LONGITUDE': bytes(config.get(section, 'LONGITUDE').ljust(9)[:9], 'utf-8'),
'HEIGHT': bytes(config.get(section, 'HEIGHT').rjust(3,'0'), 'utf-8'),
'LOCATION': bytes(config.get(section, 'LOCATION').ljust(20)[:20], 'utf-8'),
'DESCRIPTION': bytes(config.get(section, 'DESCRIPTION').ljust(19)[:19], 'utf-8'),
'SLOTS': bytes(config.get(section, 'SLOTS'), 'utf-8'),
'URL': bytes(config.get(section, 'URL').ljust(124)[:124], 'utf-8'),
'SOFTWARE_ID': bytes(config.get(section, 'SOFTWARE_ID').ljust(40)[:40], 'utf-8'),
'PACKAGE_ID': bytes(config.get(section, 'PACKAGE_ID').ljust(40)[:40], 'utf-8'),
'GROUP_HANGTIME': config.getint(section, 'GROUP_HANGTIME'),
'OPTIONS': bytes(config.get(section, 'OPTIONS'), 'utf-8'),
'XLX_MODULE': _parse_xlx_module(config, section),
'USE_ACL': config.getboolean(section, 'USE_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
'TG1_ACL': config.get(section, 'TGID_TS1_ACL'),
'TG2_ACL': config.get(section, 'TGID_TS2_ACL')
}})
CONFIG['SYSTEMS'][section].update({'STATS': {
'CONNECTION': 'NO', # NO, RTPL_SENT, AUTHENTICATED, CONFIG-SENT, YES
'CONNECTED': None,
'PINGS_SENT': 0,
'PINGS_ACKD': 0,
'NUM_OUTSTANDING': 0,
'PING_OUTSTANDING': False,
'LAST_PING_TX_TIME': 0,
'LAST_PING_ACK_TIME': 0,
}})
elif config.get(section, 'MODE') == 'SERVER':
CONFIG['SYSTEMS'].update({section: {
'MODE': config.get(section, 'MODE'),
'ENABLED': config.getboolean(section, 'ENABLED'),
'REPEAT': config.getboolean(section, 'REPEAT'),
'MAX_REPEATERS': config.getint(section, 'MAX_REPEATERS'),
'IP': getaddrinfo(config.get(section, 'IP'), 0)[0][4][0],
'PORT': config.getint(section, 'PORT'),
'PASSPHRASE': bytes(config.get(section, 'PASSPHRASE'), 'utf-8'),
'GROUP_HANGTIME': config.getint(section, 'GROUP_HANGTIME'),
'USE_ACL': config.getboolean(section, 'USE_ACL'),
'REG_ACL': config.get(section, 'REG_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
'TG1_ACL': config.get(section, 'TGID_TS1_ACL'),
'TG2_ACL': config.get(section, 'TGID_TS2_ACL')
}})
CONFIG['SYSTEMS'][section].update({'REPEATERS': {}})
elif config.get(section, 'MODE') == 'OPENBRIDGE':
CONFIG['SYSTEMS'].update({section: {
'MODE': config.get(section, 'MODE'),
'ENABLED': config.getboolean(section, 'ENABLED'),
'NETWORK_ID': config.getint(section, 'NETWORK_ID').to_bytes(4, 'big'),
'IP': getaddrinfo(config.get(section, 'IP'), 0)[0][4][0],
'PORT': config.getint(section, 'PORT'),
'PASSPHRASE': bytes(config.get(section, 'PASSPHRASE').ljust(20,'\x00')[:20], 'utf-8'),
'TARGET_SOCK': ( getaddrinfo(config.get(section, 'TARGET_IP'), 0)[0][4][0], config.getint(section, 'TARGET_PORT') ),
'TARGET_IP': getaddrinfo(config.get(section, 'TARGET_IP'), 0)[0][4][0],
'TARGET_PORT': config.getint(section, 'TARGET_PORT'),
'BOTH_SLOTS': config.getboolean(section, 'BOTH_SLOTS'),
'PRESERVE_SOURCE_PEER': config.getboolean(section, 'PRESERVE_SOURCE_PEER', fallback=False),
'USE_ACL': config.getboolean(section, 'USE_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
'TG1_ACL': config.get(section, 'TGID_ACL'),
'TG2_ACL': 'PERMIT:ALL'
}})
except configparser.Error as err:
sys.exit('Error processing configuration file -- {}'.format(err))
# XLX_MODULE is only meaningful on an OUTBOUND system: it is a client-side link
# sent to an upstream reflector. Catch it on any other mode rather than silently
# ignoring it, since a misplaced field would otherwise look like it took effect.
for _system, _sysconfig in CONFIG['SYSTEMS'].items():
if _sysconfig.get('XLX_MODULE') and _sysconfig['MODE'] != 'OUTBOUND':
sys.exit('XLX CONFIGURATION ERROR: system "{}" is MODE {} but sets XLX_MODULE. '
'XLX connections must be MODE: OUTBOUND.'.format(_system, _sysconfig['MODE']))
process_acls(CONFIG)
return CONFIG
# Used to run this file direclty and print the config,
# which might be useful for debugging
if __name__ == '__main__':
import sys
import os
import argparse
from pprint import pprint
from dmr_utils3.utils import int_id
# Change the current directory to the location of the application
os.chdir(os.path.dirname(os.path.realpath(sys.argv[0])))
# CLI argument parser - handles picking up the config file from the command line, and sending a "help" message
parser = argparse.ArgumentParser()
parser.add_argument('-c', '--config', action='store', dest='CONFIG_FILE', help='/full/path/to/config.file (usually hblink.cfg)')
cli_args = parser.parse_args()
# Ensure we have a path for the config file, if one wasn't specified, then use the execution directory
if not cli_args.CONFIG_FILE:
cli_args.CONFIG_FILE = os.path.dirname(os.path.abspath(__file__))+'/hblink.cfg'
CONFIG = build_config(cli_args.CONFIG_FILE)
pprint(CONFIG)
from bisect import bisect_right
def acl_check(_id, _acl):
id = int_id(_id)
action, singles, starts, ends = _acl
if id in singles:
return action
i = bisect_right(starts, id) - 1
if i >= 0 and id <= ends[i]:
return action
return not action
print(acl_check(b'\x00\x01\x37', CONFIG['GLOBAL']['TG1_ACL']))