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>
341 lines
17 KiB
Python
Executable File
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']))
|