Port hblink3 ACL system to dmrlink3

- const.py: add ID_MIN, ID_MAX, PEER_MAX constants
- config.py: port merge_ranges(), acl_build(), acl_check(), process_acls()
  from hblink3; parse USE_ACL/SUB_ACL in [GLOBAL] and REG_ACL per system
- dmrlink.py: import acl_check; gate master_reg_req() with per-system REG_ACL
- bridge.py: remove old file-based build_acl() and allow_sub closure; remove
  -s/--sub-acl CLI arg; call process_acls(CONFIG); use acl_check() with
  global SUB_ACL in group_voice()
- dmrlink-SAMPLE.cfg: document and add USE_ACL, SUB_ACL, REG_ACL entries

ACL semantics: REG_ACL is per-master-system (ignored on peers, which cannot
control who registers on their upstream master); SUB_ACL is global and gates
source IDs from propagating through the bridge. No TGID ACLs — bridge rules
already control TGID propagation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cort BuffingtonandClaude Sonnet 4.6 committed 2026-06-27 18:03:14 -05:00
1 parent 845158925b
commit e511b4992c
5 files changed
+105 -51

No files matched your search

+4 -50
View File
@@ -22,8 +22,6 @@
#
# bridge_rules.py defines IPSC network, timeslot, and TGID matching rules that
# determine which voice calls are bridged between systems.
#
# The optional sub_acl.py defines subscriber ACL rules (PERMIT/DENY lists).
import asyncio
import json
@@ -37,6 +35,7 @@ from time import time
from dmr_utils3.utils import bytes_3, bytes_4, int_id
from config import acl_check, process_acls
from dmrlink import (IPSC, ReportServer, TS_CLEAR_TIME, build_aliases, config_reports,
mk_ipsc_systems, run_periodic, systems)
from const import (GV_BURST_TYPE_OFF, VOICE_HEAD, VOICE_TERM, SLOT1_VOICE, SLOT2_VOICE)
@@ -112,48 +111,6 @@ def make_bridge_config(_bridge_rules):
}
# ---------------------------------------------------------------------------
# Subscriber ACL
# ---------------------------------------------------------------------------
def build_acl(_sub_acl):
ACL = set()
try:
logger.info('ACL file found, importing entries. This will take about 1.5 seconds per 1 million IDs')
acl_file = _load_file_module(_sub_acl, 'sub_acl')
if acl_file is None:
raise FileNotFoundError(_sub_acl)
sections = acl_file.ACL.split(':')
ACL_ACTION = sections[0]
for entry in sections[1].split(','):
if '-' in entry:
start, end = entry.split('-')
for rid in range(int(start), int(end) + 1):
ACL.add(bytes_3(rid))
else:
ACL.add(bytes_3(int(entry)))
logger.info('ACL loaded: action "%s" for %s radio IDs', ACL_ACTION, len(ACL))
except FileNotFoundError:
logger.info('ACL file "%s" not found — all subscriber IDs are valid', _sub_acl)
ACL_ACTION = 'NONE'
except Exception as e:
logger.warning('Error loading ACL file "%s": %s — all subscriber IDs are valid', _sub_acl, e)
ACL_ACTION = 'NONE'
global allow_sub
if ACL_ACTION == 'PERMIT':
def allow_sub(_sub):
return _sub in ACL
elif ACL_ACTION == 'DENY':
def allow_sub(_sub):
return _sub not in ACL
else:
def allow_sub(_sub):
return True
return ACL
# ---------------------------------------------------------------------------
# Rule timer loop (runs every 60 seconds)
# ---------------------------------------------------------------------------
@@ -249,7 +206,7 @@ class bridgeIPSC(IPSC):
def group_voice(self, _src_sub, _dst_group, _ts, _end, _peerid, _data):
if not allow_sub(_src_sub):
if not acl_check(_src_sub, self._CONFIG['GLOBAL']['SUB_ACL']):
logger.warning('(%s) Group Voice ***REJECTED BY ACL*** From: %s, Peer %s, Dst %s',
self._system, int_id(_src_sub), int_id(_peerid), int_id(_dst_group))
return
@@ -405,9 +362,6 @@ if __name__ == '__main__':
parser.add_argument('-b', '--bridge-rules', action='store', dest='BRIDGE_RULES',
default='bridge_rules.py',
help='path to bridge rules file (default: bridge_rules.py)')
parser.add_argument('-s', '--sub-acl', action='store', dest='SUB_ACL',
default='sub_acl.py',
help='path to subscriber ACL file (default: sub_acl.py)')
parser.add_argument('-ll', '--log_level', action='store', dest='LOG_LEVEL',
help='Override config file log level')
parser.add_argument('-lh', '--log_handle', action='store', dest='LOG_HANDLERS',
@@ -449,13 +403,13 @@ if __name__ == '__main__':
await mk_ipsc_systems(CONFIG, systems, bridgeIPSC, report_server)
process_acls(CONFIG)
CONFIG_DICT = make_bridge_config(cli_args.BRIDGE_RULES)
BRIDGE_CONF = CONFIG_DICT['BRIDGE_CONF']
TRUNKS = CONFIG_DICT['TRUNKS']
BRIDGES = CONFIG_DICT['BRIDGES']
build_acl(cli_args.SUB_ACL)
loop.create_task(run_periodic(60, rule_timer_loop, 'rule_timer', report_server))
await stop_event.wait()
+75 -1
View File
@@ -18,10 +18,14 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
import bisect
import configparser
import sys
from socket import getaddrinfo, IPPROTO_UDP
import const
from dmr_utils3.utils import int_id
__author__ = 'Cortney T. Buffington, N0MJS'
__copyright__ = 'Copyright (c) 2016-2026 Cortney T. Buffington, N0MJS and the K0USY Group'
__license__ = 'GNU GPLv3'
@@ -29,6 +33,73 @@ __maintainer__ = 'Cort Buffington, N0MJS'
__email__ = 'n0mjs@me.com'
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
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)
def acl_check(_id, _acl):
id = int_id(_id)
action, singles, starts, ends = _acl
if id in singles:
return action
i = bisect.bisect_right(starts, id) - 1
if i >= 0 and id <= ends[i]:
return action
return not action
def process_acls(_config):
# Global subscriber ACL — gates propagation through the bridge
_config['GLOBAL']['SUB_ACL'] = acl_build(_config['GLOBAL']['SUB_ACL'], const.ID_MAX)
# Per-system registration ACL — only meaningful on master systems (MASTER_PEER: True)
for system in _config['SYSTEMS']:
_config['SYSTEMS'][system]['LOCAL']['REG_ACL'] = acl_build(
_config['SYSTEMS'][system]['LOCAL']['REG_ACL'], const.PEER_MAX)
def get_address(_host):
ipv4 = ipv6 = ''
for item in getaddrinfo(_host, None, 0, 0, IPPROTO_UDP):
@@ -60,7 +131,9 @@ def build_config(_config_file):
for section in config.sections():
if section == 'GLOBAL':
CONFIG['GLOBAL'].update({
'PATH': config.get(section, 'PATH'),
'PATH': config.get(section, 'PATH'),
'USE_ACL': config.get(section, 'USE_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
})
elif section == 'REPORTS':
@@ -123,6 +196,7 @@ def build_config(_config_file):
'AUTH_KEY': bytes.fromhex(config.get(section, 'AUTH_KEY').rjust(40, '0')),
'GROUP_HANGTIME': config.getint(section, 'GROUP_HANGTIME'),
'NUM_PEERS': 0,
'REG_ACL': config.get(section, 'REG_ACL', fallback='PERMIT:ALL'),
})
CONFIG['SYSTEMS'][section]['MASTER'].update({
+5
View File
@@ -94,6 +94,11 @@ USER_PACKETS = [GROUP_VOICE, PVT_VOICE, GROUP_DATA, PVT_DATA]
# RCM Timeslot constants
TS = {b'\x00': '1', b'\x01': '2'}
# DMR ID range constants (matches hblink3)
ID_MIN = 1
ID_MAX = 16776415
PEER_MAX = 4294967295
# ---------------------------------------------------------------------------
# IPSC Bitmasks (from ipsc_mask.py)
+14
View File
@@ -9,8 +9,16 @@
# GLOBAL CONFIGURATION ITEMS
#
# USE_ACL - Master ACL enable switch. True to enforce ACLs; False to pass all traffic.
# SUB_ACL - Subscriber ID ACL (global). Gates source IDs from propagating through the
# bridge. Format: ACTION:entry[,entry...] where ACTION is PERMIT or DENY and
# entries are single IDs, ranges (start-end), or ALL.
# Examples: PERMIT:ALL DENY:3120101,3120124 PERMIT:3120000-3120999
#
[GLOBAL]
PATH: /opt/dmrlink3/
USE_ACL: True
SUB_ACL: PERMIT:ALL
# NETWORK REPORTING CONFIGURATION
@@ -113,6 +121,9 @@ STALE_DAYS: 7
# MASTER_IP - IP of the IPSC master (ignored when MASTER_PEER: True)
# MASTER_PORT - UDP port of the IPSC master (ignored when MASTER_PEER: True)
# GROUP_HANGTIME - Group call hangtime in seconds
# REG_ACL - Peer registration ACL. Only enforced when MASTER_PEER: True
# (peers cannot control who registers on their master's system).
# Same format as SUB_ACL. Omit or set PERMIT:ALL to allow all.
[SAMPLE-PEER]
@@ -139,6 +150,8 @@ AUTH_KEY: 1A2B3C
MASTER_IP: 1.2.3.4
MASTER_PORT: 50000
GROUP_HANGTIME: 5
# REG_ACL is ignored when MASTER_PEER: False — peers don't control registration
REG_ACL: PERMIT:ALL
[SAMPLE-MASTER]
@@ -164,3 +177,4 @@ AUTH_ENABLED: True
AUTH_KEY: 1A2B3C
# MASTER_IP and MASTER_PORT are ignored when MASTER_PEER is True
GROUP_HANGTIME: 5
REG_ACL: PERMIT:ALL
+7
View File
@@ -56,6 +56,8 @@ from const import (
)
from dmr_utils3.utils import bytes_2, bytes_3, bytes_4, int_id, try_download, mk_id_dict
from config import acl_check
__author__ = 'Cortney T. Buffington, N0MJS'
__copyright__ = 'Copyright (c) 2013-2026 Cortney T. Buffington, N0MJS and the K0USY Group'
__credits__ = 'Adam Fast, KC0YLK; Dave Kierzkowski, KD8EYF; Steve Zingman, N4IRS; Mike Zingman, N4IRR'
@@ -890,6 +892,11 @@ class IPSC(asyncio.DatagramProtocol):
logger.info('(%s) Requesting peer list from master', self._system)
def master_reg_req(self, _data, _peerid, _host, _port):
if not acl_check(_peerid, self._config['REG_ACL']):
logger.warning('(%s) Peer Registration ***REJECTED BY ACL***: %s %s:%s',
self._system, int_id(_peerid), _host, _port)
return
_hex_mode = _data[5:6]
_hex_flags = _data[6:10]