mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 04:19:24 +00:00
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:
1 parent
845158925b
commit
e511b4992c
5 files changed
+105
-51
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in new issue
Block a user