diff --git a/FreeDMR.cfg b/FreeDMR.cfg index 01feabf..9dd9a97 100644 --- a/FreeDMR.cfg +++ b/FreeDMR.cfg @@ -61,6 +61,7 @@ SINGLE_MODE: True VOICE_IDENT: True DIAL_A_TG: True DYNAMIC_TG_ROUTING: True +NETWORK_DIRECT_DIAL_SLOT: 0 TS1_STATIC: TS2_STATIC: DEFAULT_DIAL_TS1: 0 diff --git a/bridge_master.py b/bridge_master.py index c9cf353..3fe38a8 100644 --- a/bridge_master.py +++ b/bridge_master.py @@ -104,6 +104,7 @@ DIAL_A_TG_PROHIBITED_DEFAULTS = [ ] DIAL_A_TG_MAX = 999999 UA_TIMER_UNLIMITED = 35791394 +MAX_OPTIONS_LENGTH = 255 TIMER_OPTION_KEYS = ( 'DEFAULT_UA_TIMER', 'TG_TIMER', @@ -117,6 +118,7 @@ TIMER_OPTION_KEYS = ( ) TIMER_EXPLICIT_KEY = '_TIMER_OPTIONS_SET' DMR_ID_MAX = 16777215 +NETWORK_DIRECT_DIAL_SLOTS = (0, 1, 2) OBP_RATE_DROP_MIN_DURATION = 2.0 OBP_RATE_DROP_MIN_PACKETS = 50 OBP_RATE_DROP_MAX_PPS = 50 @@ -198,6 +200,12 @@ def parse_default_reflector_option(value, default=None): return default return int(value) +def parse_network_direct_dial_slot(value, default=None): + parsed = parse_int_option(value, default=None) + if parsed in NETWORK_DIRECT_DIAL_SLOTS: + return parsed + return default + def normalize_ua_timer(timer): return UA_TIMER_UNLIMITED if timer == 0 else timer @@ -228,6 +236,81 @@ def valid_ident_override_tg(tg): def target_requires_emb_lc_rewrite(_dst_id, _target_tgid): return _dst_id != _target_tgid +def active_reflector_for_system_slot(system, slot): + for bridge in BRIDGES: + if bridge[0:1] != '#': + continue + for bridgesystem in BRIDGES[bridge]: + if ( + bridgesystem['SYSTEM'] == system + and bridgesystem['TS'] == slot + and bridgesystem['ACTIVE'] == True + ): + return bridge + return None + +def slot_available_for_network_direct_dial(system, slot, pkt_time): + status = systems[system].STATUS[slot] + hangtime = CONFIG['SYSTEMS'][system]['GROUP_HANGTIME'] + + if status['RX_TYPE'] != HBPF_SLT_VTERM and (pkt_time - status['RX_TIME']) < STREAM_TO: + return False, 'RX active' + if status['TX_TYPE'] != HBPF_SLT_VTERM and (pkt_time - status['TX_TIME']) < STREAM_TO: + return False, 'TX active' + if (pkt_time - status['RX_TIME']) < hangtime: + return False, 'RX hangtime' + if (pkt_time - status['TX_TIME']) < hangtime: + return False, 'TX hangtime' + + active_reflector = active_reflector_for_system_slot(system, slot) + default_reflector = CONFIG['SYSTEMS'][system].get('DEFAULT_DIAL_TS{}'.format(slot), 0) + if active_reflector and active_reflector != '#{}'.format(default_reflector): + return False, 'user selected {}'.format(active_reflector) + + return True, '' + +def activate_network_direct_dial(_dst_id, pkt_time): + target_tg = int_id(_dst_id) + for system in systems: + if CONFIG['SYSTEMS'][system]['MODE'] != 'MASTER': + continue + + slot = CONFIG['SYSTEMS'][system].get('NETWORK_DIRECT_DIAL_SLOT', 0) + if slot not in (1, 2): + continue + + if not any(int_id(peer_id) == target_tg for peer_id in CONFIG['SYSTEMS'][system]['PEERS']): + continue + + slot_free, reason = slot_available_for_network_direct_dial(system, slot, pkt_time) + if not slot_free: + logger.info( + '(%s) Network Direct Dial to TG %s not activated for %s TS%s: %s', + system, target_tg, system, slot, reason + ) + continue + + route_timer = get_route_timer(system, slot, 'DIAL') + if '#{}'.format(target_tg) not in BRIDGES: + reset_all_reflector_system(route_timer, system, slot) + logger.info( + '(%s) Network Direct Dial creating TG %s as TG9 TS%s. Timeout %s', + system, target_tg, slot, route_timer + ) + make_single_reflector(_dst_id, route_timer, system, slot) + else: + reset_all_reflector_system(route_timer, system, slot) + for bridge_system in BRIDGES['#{}'.format(target_tg)]: + if bridge_system['SYSTEM'] == system and bridge_system['TS'] == slot: + bridge_system['ACTIVE'] = True + bridge_system['TIMER'] = pkt_time + bridge_system['TIMEOUT'] + bridge_system['TO_TYPE'] = 'ON' + logger.info( + '(%s) Network Direct Dial activated TG %s as TG9 TS%s. Timeout %s', + system, target_tg, slot, route_timer + ) + break + # In-call embedded LC observation. This is log-only and must not affect routing # or packet mutation. @@ -1274,6 +1357,13 @@ def options_config(): _options = {} CONFIG['SYSTEMS'][_system]['OPTIONS'] = CONFIG['SYSTEMS'][_system]['OPTIONS'].rstrip('\x00') CONFIG['SYSTEMS'][_system]['OPTIONS'] = CONFIG['SYSTEMS'][_system]['OPTIONS'].encode('ascii', 'ignore').decode() + if len(CONFIG['SYSTEMS'][_system]['OPTIONS']) > MAX_OPTIONS_LENGTH: + logger.warning( + '(OPTIONS) %s overlength options (%s bytes), ignoring', + _system, + len(CONFIG['SYSTEMS'][_system]['OPTIONS']), + ) + continue CONFIG['SYSTEMS'][_system]['OPTIONS'] = re.sub("\'","",CONFIG['SYSTEMS'][_system]['OPTIONS']) CONFIG['SYSTEMS'][_system]['OPTIONS'] = re.sub("\"","",CONFIG['SYSTEMS'][_system]['OPTIONS']) for x in CONFIG['SYSTEMS'][_system]['OPTIONS'].split(";"): @@ -1316,6 +1406,20 @@ def options_config(): _options['DIAL_A_TG'] = _options.pop('DIALTG') if 'DYNAMIC' in _options: _options['DYNAMIC_TG_ROUTING'] = _options.pop('DYNAMIC') + if 'DIAL1' in _options: + _options['DEFAULT_DIAL_TS1'] = _options.pop('DIAL1') + if 'DIAL2' in _options: + _options['DEFAULT_DIAL_TS2'] = _options.pop('DIAL2') + if 'TGTO1' in _options: + _options['TS1_TG_TIMER'] = _options.pop('TGTO1') + if 'TGTO2' in _options: + _options['TS2_TG_TIMER'] = _options.pop('TGTO2') + if 'DIALTO1' in _options: + _options['TS1_DIAL_TIMER'] = _options.pop('DIALTO1') + if 'DIALTO2' in _options: + _options['TS2_DIAL_TIMER'] = _options.pop('DIALTO2') + if 'NDD_SLOT' in _options: + _options['NETWORK_DIRECT_DIAL_SLOT'] = _options.pop('NDD_SLOT') if 'default_dial_ts1' in _options: _options['DEFAULT_DIAL_TS1'] = _options.pop('default_dial_ts1') if 'default_dial_ts2' in _options: @@ -1411,6 +1515,8 @@ def options_config(): _options['DIAL_A_TG'] = int(CONFIG['SYSTEMS'][_system].get('DIAL_A_TG', True)) if 'DYNAMIC_TG_ROUTING' not in _options: _options['DYNAMIC_TG_ROUTING'] = int(CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True)) + if 'NETWORK_DIRECT_DIAL_SLOT' not in _options: + _options['NETWORK_DIRECT_DIAL_SLOT'] = CONFIG['SYSTEMS'][_system].get('NETWORK_DIRECT_DIAL_SLOT', 0) _default_dial_ts1 = parse_default_reflector_option(_options['DEFAULT_DIAL_TS1'], default=None) if _default_dial_ts1 is None: @@ -1480,6 +1586,11 @@ def options_config(): if _dynamic_tg_routing is None: logger.debug('(OPTIONS) %s - DYNAMIC_TG_ROUTING is not 0 or 1, ignoring',_system) _dynamic_tg_routing = CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True) + + _network_direct_dial_slot = parse_network_direct_dial_slot(_options['NETWORK_DIRECT_DIAL_SLOT'], default=None) + if _network_direct_dial_slot is None: + logger.debug('(OPTIONS) %s - NETWORK_DIRECT_DIAL_SLOT is not 0, 1, or 2, ignoring',_system) + _network_direct_dial_slot = CONFIG['SYSTEMS'][_system].get('NETWORK_DIRECT_DIAL_SLOT', 0) if 'TS1_STATIC' not in _options or 'TS2_STATIC' not in _options or 'DEFAULT_DIAL_TS1' not in _options or 'DEFAULT_DIAL_TS2' not in _options or 'DEFAULT_UA_TIMER' not in _options: logger.debug('(OPTIONS) %s - Required field missing, ignoring',_system) @@ -1541,6 +1652,9 @@ def options_config(): if _dynamic_tg_routing != CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True): logger.debug('(OPTIONS) %s DYNAMIC_TG_ROUTING changed to %s',_system,_dynamic_tg_routing) + + if _network_direct_dial_slot != CONFIG['SYSTEMS'][_system].get('NETWORK_DIRECT_DIAL_SLOT', 0): + logger.debug('(OPTIONS) %s NETWORK_DIRECT_DIAL_SLOT changed to %s',_system,_network_direct_dial_slot) ts1 = [] if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_options['TS1_STATIC'] != CONFIG['SYSTEMS'][_system]['TS1_STATIC']): @@ -1569,6 +1683,7 @@ def options_config(): CONFIG['SYSTEMS'][_system][TIMER_EXPLICIT_KEY] = _timer_values[TIMER_EXPLICIT_KEY] CONFIG['SYSTEMS'][_system]['DIAL_A_TG'] = _dial_a_tg CONFIG['SYSTEMS'][_system]['DYNAMIC_TG_ROUTING'] = _dynamic_tg_routing + CONFIG['SYSTEMS'][_system]['NETWORK_DIRECT_DIAL_SLOT'] = _network_direct_dial_slot if '_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']: CONFIG['SYSTEMS'][_system]['_reloadoptions'] = False @@ -2176,6 +2291,9 @@ class routerOBP(OPENBRIDGE): if int_id(_dst_id) >= 5 and int_id(_dst_id) != 9 and (str(int_id(_dst_id)) not in BRIDGES): logger.debug('(%s) Bridge for STAT TG %s does not exist. Creating',self._system, int_id(_dst_id)) make_stat_bridge(_dst_id) + + if not _data_control: + activate_network_direct_dial(_dst_id, pkt_time) _sysIgnore = deque() for _bridge in BRIDGES: diff --git a/config.py b/config.py index c182b82..c29962a 100755 --- a/config.py +++ b/config.py @@ -29,6 +29,7 @@ change. import configparser import sys import const +import logging import socket import ipaddress @@ -44,6 +45,20 @@ __license__ = 'GNU GPLv3' __maintainer__ = 'Simon Adlem, G7RZU' __email__ = 'simon@gb7fr.org.uk' +logger = logging.getLogger(__name__) + +def config_network_direct_dial_slot(config, section): + try: + slot = config.getint(section, 'NETWORK_DIRECT_DIAL_SLOT', fallback=0) + except ValueError: + logger.warning('(%s) NETWORK_DIRECT_DIAL_SLOT is not an integer, defaulting to 0', section) + return 0 + + if slot not in (0, 1, 2): + logger.warning('(%s) NETWORK_DIRECT_DIAL_SLOT is not 0, 1, or 2, defaulting to 0', section) + return 0 + return slot + def master_timer_config(config, section): default_timer = config.getint(section, 'DEFAULT_UA_TIMER', fallback=10) tg_timer = config.getint(section, 'TG_TIMER', fallback=default_timer) @@ -357,6 +372,7 @@ def build_config(_config_file): 'VOICE_IDENT': config.getboolean(section, 'VOICE_IDENT', fallback=True), 'DIAL_A_TG': config.getboolean(section, 'DIAL_A_TG', fallback=True), 'DYNAMIC_TG_ROUTING': config.getboolean(section, 'DYNAMIC_TG_ROUTING', fallback=True), + 'NETWORK_DIRECT_DIAL_SLOT': config_network_direct_dial_slot(config, section), 'TS1_STATIC': config.get(section,'TS1_STATIC', fallback=''), 'TS2_STATIC': config.get(section,'TS2_STATIC', fallback=''), 'DEFAULT_DIAL_TS1': config.getint(section, 'DEFAULT_DIAL_TS1', fallback=0), diff --git a/docker-configs/freedmr.cfg b/docker-configs/freedmr.cfg index 01feabf..9dd9a97 100644 --- a/docker-configs/freedmr.cfg +++ b/docker-configs/freedmr.cfg @@ -61,6 +61,7 @@ SINGLE_MODE: True VOICE_IDENT: True DIAL_A_TG: True DYNAMIC_TG_ROUTING: True +NETWORK_DIRECT_DIAL_SLOT: 0 TS1_STATIC: TS2_STATIC: DEFAULT_DIAL_TS1: 0 diff --git a/docs/codex-notes.md b/docs/codex-notes.md new file mode 100644 index 0000000..c799423 --- /dev/null +++ b/docs/codex-notes.md @@ -0,0 +1,49 @@ +# Codex Notes + +## Network Direct Dial + +Findings: +- HBP `RPTO` options are variable-length in DMRGateway and null-padded options + are already trimmed in FreeDMR. +- DMRGateway bounds normal Homebrew `DMRD` packets to fixed length and uses a + 300-byte stack buffer for outbound `RPTO`; a 255-byte receive limit in + FreeDMR is conservative and compatible. +- FreeDMR can implement repeater-ID network direct dial without a new packet + forwarding path by activating an existing `#` dial route and reusing the + current TG9 rewrite logic. +- FreeDMR1 still treats HBP `SLOTS` as repeater-advertised metadata from the + RPTC config packet or legacy PEER config. It is not currently a master-side + routing policy knob for dynamic/dial behavior. + +Assumptions: +- `NETWORK_DIRECT_DIAL_SLOT=0` means disabled; `1` presents TG9 TS1; `2` + presents TG9 TS2 for DMO/simplex clients. +- Network direct dial is client/session-overridable through `OPTIONS`; the + compact alias is `NDD_SLOT`. +- RF-originated traffic must not activate network direct dial. +- Invalid `NETWORK_DIRECT_DIAL_SLOT` values in config should fall back to `0` + and log a warning; invalid OPTIONS values should leave the current session + value unchanged. + +Unresolved questions: +- Whether to add a later local CW `N` notification for blocked network direct + dial attempts. +- Whether a broader HBP bounds-validation pass should reject all non-exact + `DMRD` lengths rather than only guarding short packets. +- Whether FreeDMR2 should promote HBP `SLOTS`/DMO capability into an explicit + access-session capability model. FreeDMR1 should not infer this for network + direct dial. + +Protocol-sensitive areas: +- Network direct dial is a narrow exception to the normal rule that network + audio does not change local RF-facing dial state. +- It must not override active or hangtime user-selected dial-a-TG state. +- It must preserve existing packet rewrite boundaries by using the established + TG9 reflector bridge path. + +Inferred invariants: +- General private voice routing remains disabled. +- Repeater/client-directed group-call access is allowed only when explicitly + configured and the selected RF slot is idle. +- Overlength `OPTIONS` should be acknowledged but ignored to avoid client + reconnect loops while protecting parser state. diff --git a/docs/options-cheatsheet.md b/docs/options-cheatsheet.md index c9b9cff..de13b5e 100644 --- a/docs/options-cheatsheet.md +++ b/docs/options-cheatsheet.md @@ -16,6 +16,10 @@ KEY=secret;TS1=91,92;TS2=235;DEFAULT_DIAL_TS1=4400;DEFAULT_DIAL_TS2=2350;TIMER=1 Keep key names exact. Avoid spaces around key names. Commas inside TG lists may have simple whitespace. +FreeDMR accepts `OPTIONS` strings up to 255 bytes. Longer strings are rejected +and the current/default session settings remain in use. Use the compact aliases +below where possible on embedded clients. + ## Common Examples Static TGs on both slots: @@ -66,6 +70,18 @@ Use a user API key: KEY=my-secret-key ``` +Allow network users to direct-dial this repeater/hotspot ID onto local TG9: + +```text +NDD_SLOT=1 +``` + +For DMO/simplex hotspots, use slot 2: + +```text +NDD_SLOT=2 +``` + ## Supported Fields | Field | Meaning | @@ -84,6 +100,7 @@ KEY=my-secret-key | `TS2_TG_TIMER` | Slot 2 conventional TG timeout in minutes. | | `TS1_DIAL_TIMER` | Slot 1 dial-a-TG timeout in minutes. | | `TS2_DIAL_TIMER` | Slot 2 dial-a-TG timeout in minutes. | +| `NETWORK_DIRECT_DIAL_SLOT` | Network direct dial slot. `0` disables it, `1` presents on TG9 TS1, `2` presents on TG9 TS2. | | `DIALTG` | Enable/disable dial-a-TG private-call control. Use `1` or `0`. | | `DYNAMIC` | Enable/disable automatic dynamic conventional TG routing. Use `1` or `0`. | | `VOICE` | Enable/disable voice ident. Use `1` or `0`. | @@ -111,6 +128,26 @@ These are still accepted for compatibility: | `TS2_1` ... `TS2_9` | DMR+ style slot-2 static TG fields. Combined into `TS2`. | | `UserLink` | Accepted and ignored. | +## Compact Aliases + +These aliases are preferred in HBP `OPTIONS` strings where space is limited: + +| Alias | Current meaning | +| --- | --- | +| `DIAL1` | Alias for `DEFAULT_DIAL_TS1`. | +| `DIAL2` | Alias for `DEFAULT_DIAL_TS2`. | +| `TGTO1` | Alias for `TS1_TG_TIMER`. | +| `TGTO2` | Alias for `TS2_TG_TIMER`. | +| `DIALTO1` | Alias for `TS1_DIAL_TIMER`. | +| `DIALTO2` | Alias for `TS2_DIAL_TIMER`. | +| `NDD_SLOT` | Alias for `NETWORK_DIRECT_DIAL_SLOT`. | + +Example compact string: + +```text +KEY=secret;TS1=91,92;TS2=235;DIAL1=4400;DIAL2=2350;TGTO1=10;TGTO2=10;DIALTO1=30;DIALTO2=30;NDD_SLOT=1 +``` + ## Dial-a-TG Notes - Dial-a-TG uses RF-visible TG9 on the relevant slot. @@ -118,6 +155,14 @@ These are still accepted for compatibility: - Private-call control on slot 1 controls slot 1. - Private-call control on slot 2 controls slot 2. - Voice prompts for dial-a-TG are sent on TG9 on the controlling slot. +- Network direct dial is a narrow exception where network-originated group + traffic to the repeater/hotspot's own ID may activate local TG9 on the + configured slot. It does not enable general private voice routing. +- Network direct dial does not override an active or hangtime user-selected + dial-a-TG route. +- Invalid `NETWORK_DIRECT_DIAL_SLOT` values in server config fall back to `0` + and are logged. Invalid `NDD_SLOT`/`NETWORK_DIRECT_DIAL_SLOT` values in + client `OPTIONS` are ignored and the current session value remains in use. - `DEFAULT_REFLECTOR`, `DIAL`, and `StartRef` only affect the slot-2 default. Use `DEFAULT_DIAL_TS1` for slot 1. diff --git a/docs/test-harness-design.md b/docs/test-harness-design.md index 051cfb4..de49fc3 100644 --- a/docs/test-harness-design.md +++ b/docs/test-harness-design.md @@ -143,6 +143,12 @@ for rule timeout checks without sleeping. in-memory effective default should normalize to `0` without writing back to the config file. System-wide defaults are intended for sparing use; client requested settings are preferential. +- Network direct dial behaviour: when `NETWORK_DIRECT_DIAL_SLOT`/`NDD_SLOT` is + set to `1` or `2`, network-originated group traffic to the connected + repeater/hotspot ID may activate local TG9 on that slot. Tests must verify it + is disabled by default, does not affect RF-originated traffic, does not + override active/hangtime user-selected dial-a-TG state, and uses the existing + TG9 rewrite path rather than a new packet mutation path. - Static TG configuration bugs: startup and live options reload should reject prohibited local/control TGs consistently on both TS1 and TS2 after parsing the configured TG strings to integers. Invalid IDs at or above `16777215` should diff --git a/docs/testing.md b/docs/testing.md index bfeba96..12d2587 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -91,6 +91,12 @@ Invalid specific timer fields are logged and ignored so valid broader timer fields still apply. Voice ident override coverage verifies valid override TGs are used, empty/false overrides use all-call, and malformed, control, or all-call override values are logged and fall back to all-call. +Network direct dial coverage verifies the feature is disabled by default, +routes network-originated calls to a connected repeater/hotspot ID as RF-visible +TG9 on the configured slot, supports TS2 for DMO/simplex clients, does not +override a user-selected dial-a-TG route, can replace an idle configured default +dial route, and is not activated by RF-originated traffic. OPTIONS coverage also +verifies compact aliases and overlength `OPTIONS` rejection. Generated voice prompt helper coverage verifies prompt stream state is attached to the router instance sending the prompt, even if a stale module-level `system` variable names another master. This protects dial-a-TG prompts, idents, @@ -117,6 +123,16 @@ to another FBP peer is covered for source server, source repeater, hops, BER and RSSI metadata preservation. The OpenBridge parser seam is covered for truncated `DMRE` packets so malformed UDP input is logged and discarded before fixed-offset parsing can raise. +UDP black-box coverage includes FBP-originated network direct dial to a +connected HBP client, asserting the observed RF-facing packet is TG9 on the +configured slot. +FBP protocol-version coverage asserts that a configured v4 link can still emit +v4 layout when explicitly negotiated/configured, but an established v5 session +is not downgraded by a later v4 packet. +OBP/FBP rate-drop threshold behavior is asserted in the deterministic harness, +where fake time makes the packet-rate calculation exact. UDP black-box coverage +keeps a sustained high-rate FBP load case, but treats it as an observable +stability/proxy-error check rather than a precise rate-accounting assertion. Enhanced OpenBridge bridge-control coverage verifies a valid generated `BCST` STUN packet sets the global `STUN` traffic gate. Dial-a-TG source-quench coverage verifies HBP-to-FBP reflector forwarding checks diff --git a/docs/v1x-codex-changelog.md b/docs/v1x-codex-changelog.md index db3ab54..eb5bcc1 100644 --- a/docs/v1x-codex-changelog.md +++ b/docs/v1x-codex-changelog.md @@ -24,6 +24,13 @@ to TS2; explicit `DEFAULT_DIAL_TS2` takes precedence. - Added validation/logging so invalid default dial values do not create bridge state and normalize to no default for the active runtime session. +- Added compact HBP `OPTIONS` aliases for newer slot/default/timer settings: + `DIAL1`, `DIAL2`, `TGTO1`, `TGTO2`, `DIALTO1`, `DIALTO2` and `NDD_SLOT`. +- Bounded received HBP `OPTIONS` to 255 bytes. Overlength options are + acknowledged but ignored so clients do not reconnect-loop and oversized + strings do not enter session state. +- Added startup validation for `NETWORK_DIRECT_DIAL_SLOT`; invalid values fall + back to disabled (`0`) and are logged. ## Dial-a-TG @@ -40,6 +47,17 @@ - Preserved the current FreeDMR dial-a-TG policy cap of `999999`. - Ensured FBP route targets created for dial-a-TG remain active across local retunes/disconnects, in line with the mesh "everything everywhere" model. +- Added opt-in network direct dial: a network-originated group call to a + connected repeater/hotspot's own ID can be presented locally as TG9 on the + configured slot. This is configured with `NETWORK_DIRECT_DIAL_SLOT` or + `NDD_SLOT` (`0` disabled, `1` TG9 TS1, `2` TG9 TS2). +- Network direct dial is a deliberate, narrow exception where network traffic + may activate the local RF TG9 presentation path. It does not override an + active or hangtime user-selected dial-a-TG route and does not apply to + RF-originated traffic. +- Existing HBP `SLOTS` data remains repeater-advertised metadata. FreeDMR1 does + not use it as a master-side routing policy for network direct dial; the new + explicit setting is `NETWORK_DIRECT_DIAL_SLOT`. ## Data Path diff --git a/freedmr.cfg b/freedmr.cfg index 01feabf..9dd9a97 100644 --- a/freedmr.cfg +++ b/freedmr.cfg @@ -61,6 +61,7 @@ SINGLE_MODE: True VOICE_IDENT: True DIAL_A_TG: True DYNAMIC_TG_ROUTING: True +NETWORK_DIRECT_DIAL_SLOT: 0 TS1_STATIC: TS2_STATIC: DEFAULT_DIAL_TS1: 0 diff --git a/hblink.py b/hblink.py index 7921c92..feac741 100755 --- a/hblink.py +++ b/hblink.py @@ -51,6 +51,8 @@ from utils import bytes_4, int_id, mk_id_dict, try_download,load_json,blake2bsum import pickle from reporting_const import * +MAX_OPTIONS_LENGTH = 255 + # The module needs logging logging, but handlers, etc. are controlled by the parent import logging logger = logging.getLogger(__name__) @@ -1070,10 +1072,18 @@ class HBSYSTEM(DatagramProtocol): _peer_id = _data[4:8] if _peer_id in self._peers and self._peers[_peer_id]['SOCKADDR'] == _sockaddr: _this_peer = self._peers[_peer_id] - _this_peer['OPTIONS'] = _data[8:] self.send_peer(_peer_id, b''.join([RPTACK, _peer_id])) + if len(_data[8:]) > MAX_OPTIONS_LENGTH: + logger.warning( + '(%s) Peer %s sent overlength options (%s bytes), ignoring', + self._system, + _this_peer['CALLSIGN'], + len(_data[8:]), + ) + return + _this_peer['OPTIONS'] = _data[8:].rstrip(b'\x00') logger.info('(%s) Peer %s has sent options %s', self._system, _this_peer['CALLSIGN'], _this_peer['OPTIONS']) - self._CONFIG['SYSTEMS'][self._system]['OPTIONS'] = _this_peer['OPTIONS'].decode() + self._CONFIG['SYSTEMS'][self._system]['OPTIONS'] = _this_peer['OPTIONS'].decode('ascii', errors='ignore') else: self.transport.write(b''.join([MSTNAK, _peer_id]), _sockaddr) logger.info('(%s) Options from Radio ID that is not logged: %s', self._system, int_id(_peer_id)) diff --git a/tests/harness/deterministic.py b/tests/harness/deterministic.py index 3aa1bea..abce580 100644 --- a/tests/harness/deterministic.py +++ b/tests/harness/deterministic.py @@ -315,6 +315,7 @@ def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) -> "VOICE_IDENT": False, "DIAL_A_TG": True, "DYNAMIC_TG_ROUTING": True, + "NETWORK_DIRECT_DIAL_SLOT": 0, "TS1_STATIC": "", "TS2_STATIC": "", "DEFAULT_DIAL_TS1": 0, diff --git a/tests/harness/udp_blackbox.py b/tests/harness/udp_blackbox.py index a5dd9a3..d34ee71 100644 --- a/tests/harness/udp_blackbox.py +++ b/tests/harness/udp_blackbox.py @@ -469,6 +469,7 @@ def write_bridge_master_config( ts2_static: str = "91", dial_a_tg: bool = True, dynamic_tg_routing: bool = True, + network_direct_dial_slot: int = 0, master_extra_config: str = "", ) -> None: global_use_acl_text = "True" if global_use_acl else "False" @@ -497,6 +498,7 @@ SINGLE_MODE: True VOICE_IDENT: False DIAL_A_TG: {dial_a_tg_text} DYNAMIC_TG_ROUTING: {dynamic_tg_routing_text} +NETWORK_DIRECT_DIAL_SLOT: {network_direct_dial_slot} TS1_STATIC: {ts1_static} TS2_STATIC: {ts2_static} DEFAULT_DIAL_TS1: 0 @@ -974,6 +976,7 @@ class UdpBlackBoxScenario: ts2_static: str = "91", dial_a_tg: bool = True, dynamic_tg_routing: bool = True, + network_direct_dial_slot: int = 0, master_extra_config: str = "", fbp_systems: dict[str, int] | None = None, fbp_proto_versions: dict[str, int] | None = None, @@ -997,6 +1000,7 @@ class UdpBlackBoxScenario: "ts2_static": ts2_static, "dial_a_tg": dial_a_tg, "dynamic_tg_routing": dynamic_tg_routing, + "network_direct_dial_slot": network_direct_dial_slot, "master_extra_config": master_extra_config, } diff --git a/tests/test_deterministic_harness.py b/tests/test_deterministic_harness.py index 2d4ecc0..633aacb 100644 --- a/tests/test_deterministic_harness.py +++ b/tests/test_deterministic_harness.py @@ -84,6 +84,7 @@ DEFAULT_REFLECTOR: 0 self.assertEqual(parsed["ALIASES"]["STALE_TIME"], 86400) self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DIAL_A_TG"]) self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DYNAMIC_TG_ROUTING"]) + self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 0) self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"], 0) self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 0) @@ -141,6 +142,35 @@ DEFAULT_DIAL_TS2: 92 self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 92) self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 92) + def test_config_invalid_network_direct_dial_slot_defaults_to_disabled(self): + for value in ("3", "A"): + with self.subTest(value=value): + config_text = f""" +[GLOBAL] +USE_ACL: False + +[ALIASES] +TRY_DOWNLOAD: False +STALE_DAYS: 1 + +[MASTER-A] +MODE: MASTER +ENABLED: True +NETWORK_DIRECT_DIAL_SLOT: {value} +""" + fd, path = tempfile.mkstemp(suffix=".cfg") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(config_text) + + with self.assertLogs("config", level="WARNING") as logs: + parsed = freedmr_config.build_config(path) + finally: + os.unlink(path) + + self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 0) + self.assertIn("NETWORK_DIRECT_DIAL_SLOT", "\n".join(logs.output)) + def test_set_alias_updates_bridge_master_globals_and_shared_hblink_config(self): with DeterministicScenario() as scenario: bm = scenario.bm @@ -596,6 +626,47 @@ DEFAULT_DIAL_TS2: 92 self.assertEqual(ts1_dial["TIMEOUT"], 40 * 60) self.assertEqual(ts2_dial["TIMEOUT"], 6 * 60) + def test_options_compact_aliases_update_dial_timers_and_network_direct_dial(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = ( + "DIAL1=235;DIAL2=236;TGTO1=5;TGTO2=6;DIALTO1=7;DIALTO2=8;NDD_SLOT=2" + ) + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"], 235) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 236) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS1_TG_TIMER"], 5) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS2_TG_TIMER"], 6) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS1_DIAL_TIMER"], 7) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS2_DIAL_TIMER"], 8) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 2) + + def test_options_invalid_network_direct_dial_slot_is_ignored(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1 + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "NDD_SLOT=3;DIAL=0;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + with self.assertLogs(scenario.bm.logger, level="DEBUG") as logs: + scenario.bm.options_config() + + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 1) + self.assertIn("NETWORK_DIRECT_DIAL_SLOT is not 0, 1, or 2", "\n".join(logs.output)) + + def test_options_overlength_string_is_ignored(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1 + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "A" * 256 + + with DeterministicScenario(config=config) as scenario: + with self.assertLogs(scenario.bm.logger, level="WARNING") as logs: + scenario.bm.options_config() + + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 1) + self.assertIn("overlength options", "\n".join(logs.output)) + def test_options_invalid_specific_timer_falls_back_without_blocking_valid_fields(self): config = minimal_config(("MASTER-A", "MASTER-B")) config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = ( @@ -1271,6 +1342,116 @@ DEFAULT_DIAL_TS2: 92 self.assertEqual(fbp_entry["TO_TYPE"], "NONE") self.assertEqual(scenario.capture.packets, []) + def test_network_direct_dial_is_disabled_by_default(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", 1001) + scenario.clock.advance(6) + + scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1)) + + self.assertNotIn("#1001", scenario.bridge_state) + self.assertEqual(scenario.capture.for_system("MASTER-A"), []) + + def test_network_direct_dial_routes_repeater_id_to_tg9_on_configured_slot(self): + config = minimal_config(("MASTER-A",)) + config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1 + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", 1001) + scenario.clock.advance(6) + + scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1)) + + captured = scenario.capture.for_system("MASTER-A") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(9)) + self.assertEqual(captured[0].fields["slot"], 1) + entry = next( + entry for entry in scenario.bridge_state["#1001"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + self.assertTrue(entry["ACTIVE"]) + + def test_network_direct_dial_can_route_to_slot2_for_dmo_clients(self): + config = minimal_config(("MASTER-A",)) + config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 2 + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", 1001) + scenario.clock.advance(6) + + scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1)) + + captured = scenario.capture.for_system("MASTER-A") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(9)) + self.assertEqual(captured[0].fields["slot"], 2) + + def test_network_direct_dial_does_not_override_user_selected_dial_tg(self): + config = minimal_config(("MASTER-A",)) + config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1 + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", 1001) + scenario.bm.make_single_reflector(bytes_3(235), 1, "MASTER-A", 1) + scenario.clock.advance(6) + + scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1)) + + self.assertNotIn("#1001", scenario.bridge_state) + source_entry = next( + entry for entry in scenario.bridge_state["#235"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + self.assertTrue(source_entry["ACTIVE"]) + self.assertEqual(scenario.capture.for_system("MASTER-A"), []) + + def test_network_direct_dial_can_override_idle_default_dial_tg(self): + config = minimal_config(("MASTER-A",)) + config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1 + config["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"] = 235 + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", 1001) + scenario.bm.make_default_reflector(235, 1, "MASTER-A", 1) + scenario.clock.advance(6) + + scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1)) + + captured = scenario.capture.for_system("MASTER-A") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(9)) + self.assertEqual(captured[0].fields["slot"], 1) + default_entry = next( + entry for entry in scenario.bridge_state["#235"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + direct_entry = next( + entry for entry in scenario.bridge_state["#1001"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + self.assertFalse(default_entry["ACTIVE"]) + self.assertTrue(direct_entry["ACTIVE"]) + + def test_rf_traffic_to_own_repeater_id_does_not_activate_network_direct_dial(self): + config = minimal_config(("MASTER-A",)) + config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1 + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", 1001) + scenario.clock.advance(6) + + scenario.inject_hbp("MASTER-A", PacketSpec(dst_id=1001, slot=1)) + + self.assertNotIn("#1001", scenario.bridge_state) + def test_dial_a_tg_bcsq_uses_reflector_tg_for_fbp_target(self): config = minimal_config(("MASTER-A", "MASTER-B")) add_openbridge_system(config, "OBP-1", network_id=3001) @@ -2104,6 +2285,23 @@ DEFAULT_DIAL_TS2: 92 self.assertIn("DMRD packet too short", "\n".join(logs.output)) self.assertEqual(scenario.capture.for_system("MASTER-A"), []) + def test_hbp_master_parser_acks_but_ignores_overlength_options(self): + config = minimal_config(("MASTER-A",)) + + with DeterministicScenario(config=config) as scenario: + sockaddr = ("127.0.0.1", 50000) + scenario.register_peer("MASTER-A", peer_id=1001, sockaddr=sockaddr) + packet = b"RPTO" + bytes_4(1001) + (b"A" * 256) + + with self.assertLogs("hblink", level="WARNING") as logs: + scenario.inject_datagram("MASTER-A", packet, sockaddr=sockaddr) + + ack, ack_sockaddr = scenario.transports["MASTER-A"].writes[-1] + self.assertEqual(ack, b"RPTACK" + bytes_4(1001)) + self.assertEqual(ack_sockaddr, sockaddr) + self.assertNotIn("OPTIONS", config["SYSTEMS"]["MASTER-A"]) + self.assertIn("overlength options", "\n".join(logs.output)) + def test_obp_bridge_control_bcst_sets_global_stun_flag(self): config = minimal_config(()) add_openbridge_system(config, "OBP-1", network_id=3001) diff --git a/tests/test_udp_blackbox_harness.py b/tests/test_udp_blackbox_harness.py index 2a299a7..48cb0e8 100644 --- a/tests/test_udp_blackbox_harness.py +++ b/tests/test_udp_blackbox_harness.py @@ -935,7 +935,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): self.assertEqual(captured.fields["fbp_version"], 5) self.assertEqual(captured.fields["source_rptr"], bytes_4(1001)) - def test_fbp_v4_packet_downgrades_session_to_v4_layout_for_compatibility(self): + def test_fbp_v4_packet_does_not_downgrade_v5_session_layout(self): require_udp_integration_enabled() with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: @@ -977,8 +977,9 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): master_b.close() self.assertEqual(captured.packet[:4], b"DMRE") - self.assertEqual(len(captured.packet), 85) - self.assertEqual(captured.fields["source_rptr"], b"\x00\x00\x00\x00") + self.assertEqual(len(captured.packet), 89) + self.assertEqual(captured.fields["fbp_version"], 5) + self.assertEqual(captured.fields["source_rptr"], bytes_4(1001)) def test_fbp_configured_proto_v4_outbound_packet_carries_v4_version_byte(self): require_udp_integration_enabled() @@ -1218,12 +1219,51 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): self.assertEqual(captured.fields["slot"], 2) self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020305)) - def test_fbp_sustained_over_rate_stream_is_dropped_without_proxy_error(self): + def test_fbp_network_direct_dial_routes_repeater_id_to_local_tg9(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario( + fbp_systems={"OBP-1": 3001}, + network_direct_dial_slot=1, + ) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=1002, + slot=1, + stream_id=0x01020325, + ), + source_server=9991, + source_rptr=1001, + ) + captured = master_b.recv(timeout=2.0) + finally: + master_b.close() + + self.assertEqual(captured.packet[:4], b"DMRD") + self.assertEqual(captured.fields["rf_src"], bytes_3(3120001)) + self.assertEqual(captured.fields["dst_id"], bytes_3(9)) + self.assertEqual(captured.fields["slot"], 1) + self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020325)) + + def test_fbp_sustained_high_rate_stream_does_not_trigger_proxy_error(self): require_udp_integration_enabled() stream_id = 0x01020316 with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: fbp_peer = scenario.fbp_peer("OBP-1") + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) fbp_peer.send_fbp( PacketSpec( @@ -1240,7 +1280,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): ) time.sleep(2.0) - for seq in range(1, 102): + for seq in range(1, 201): fbp_peer.send_fbp( PacketSpec( peer_id=3001, @@ -1254,9 +1294,12 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): source_server=9991, source_rptr=1001, ) + time.sleep(0.001) - output = scenario.process.wait_for_log("*PacketControl* RATE DROP!", timeout=3.0) + time.sleep(0.5) + output = scenario.process.output() + self.assertIn("*CALL START*", output) self.assertIn("PEER: 3001", output) self.assertIn("SRC: 9991", output) self.assertNotIn("proxy_BadPeer", output)