Files
hblink3/tests/test_protocol.py
T
Cort BuffingtonandClaude Opus 4.8 2ac1437ca5 Don't forward bridged traffic to a peer not logged into its master
When HBlink3 acts as a PEER, the router forwarded bridged call traffic to it
regardless of whether it had completed login to its upstream master. The frames
were fired at the master's address and silently dropped (we're not registered),
and the call falsely appeared as an active forward on the dashboard. This is
long-standing behavior (master/original hblink3 did the same) -- the new
event-sourced dashboard just made it visible.

Add HBSYSTEM.egress_ready() (True for masters/OpenBridges; for a PEER, True only
once CONNECTION == 'YES') and skip un-ready targets at the top of routerHBP
bridge_group / bridge_unit, so a disconnected peer gets no forwarded frames, no
TX report event, and no TX status churn. send_master also drops DMRD when not
connected as defense-in-depth, while still letting login/keepalive packets flow.

The routing harness models connected, participating systems, so it now marks its
PEER systems CONNECTION == 'YES' -- the 15-scenario byte baseline is unchanged.
Tests: tests/test_peer_egress.py (connected forwards, disconnected skipped) plus
a send_master drop/allow test; full suite 51 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:00:55 -05:00

252 lines
10 KiB
Python

#!/usr/bin/env python
#
# Regression tests for the asyncio migration's protocol mechanics -- the parts
# the routing harness does not exercise: the master login handshake state
# machine, the reporting server's netstring framing, and that the UDP send paths
# use transport.sendto(). Driven against mock transports (no real sockets).
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import json
import logging
import os
import sys
import unittest
from hashlib import sha256
from binascii import a2b_hex as bhex
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
sys.path.insert(0, _HERE)
import config
import hblink
# Quiet the protocol's INFO chatter (assertLogs elsewhere still works, since it
# temporarily lowers the level within its own context).
logging.getLogger('hblink').setLevel(logging.CRITICAL)
from dmr_utils3.utils import bytes_3, bytes_4
from const import RPTL, RPTK, RPTC, RPTACK, MSTNAK, DMRD, HBPF_SLT_VHEAD, HBPF_SLT_VTERM
CFG = config.build_config(os.path.join(_HERE, 'harness.cfg'))
class MockDatagramTransport:
"""Captures sendto(data, addr) like an asyncio DatagramTransport."""
def __init__(self):
self.sent = []
def sendto(self, data, addr=None):
self.sent.append((bytes(data), addr))
class MockStreamTransport:
"""Captures write(data) like an asyncio (TCP) Transport."""
def __init__(self, peername=('127.0.0.1', 50001)):
self.written = bytearray()
self._peername = peername
self.closed = False
def write(self, data):
self.written += data
def get_extra_info(self, key):
return self._peername if key == 'peername' else None
def close(self):
self.closed = True
class TestMasterHandshake(unittest.TestCase):
"""Drive a MASTER through RPTL -> RPTK -> RPTC over datagram_received and
assert the state machine and responses, exercising the asyncio rename
(datagram_received) and transport.sendto path."""
def setUp(self):
self.master = hblink.HBSYSTEM('MASTER-1', CFG, None)
self.master.transport = MockDatagramTransport() # bypass connection_made's task
self.peer_id = bytes_4(312000)
self.addr = ('127.0.0.1', 50000)
self.passphrase = CFG['SYSTEMS']['MASTER-1']['PASSPHRASE']
def test_full_login_exchange(self):
m = self.master
# 1) Login request -> challenge
m.datagram_received(RPTL + self.peer_id, self.addr)
self.assertIn(self.peer_id, m._peers)
self.assertEqual(m._peers[self.peer_id]['CONNECTION'], 'CHALLENGE_SENT')
self.assertEqual(m.transport.sent[-1][0][:len(RPTACK)], RPTACK)
# 2) Answer the challenge with the correct hash -> WAITING_CONFIG
salt = bytes_4(m._peers[self.peer_id]['SALT'])
calc = bhex(sha256(salt + self.passphrase).hexdigest())
m.datagram_received(RPTK + self.peer_id + calc, self.addr)
self.assertEqual(m._peers[self.peer_id]['CONNECTION'], 'WAITING_CONFIG')
self.assertEqual(m.transport.sent[-1][0], RPTACK + self.peer_id)
# 3) Send configuration -> connected
rptc = RPTC + self.peer_id + b'TEST ' + b'\x00' * 300
m.datagram_received(rptc, self.addr)
self.assertEqual(m._peers[self.peer_id]['CONNECTION'], 'YES')
self.assertEqual(m.transport.sent[-1][0], RPTACK + self.peer_id)
def test_wrong_passphrase_is_rejected(self):
m = self.master
m.datagram_received(RPTL + self.peer_id, self.addr)
bad = bhex(sha256(b'\x00\x00\x00\x00' + b'wrong').hexdigest())
m.datagram_received(RPTK + self.peer_id + bad, self.addr)
# Peer removed and a NAK sent
self.assertNotIn(self.peer_id, m._peers)
self.assertEqual(m.transport.sent[-1][0][:6], MSTNAK)
class TestReportingNDJSON(unittest.TestCase):
def _client(self, peer=('127.0.0.1', 51000)):
factory = hblink.reportFactory(CFG)
proto = factory() # factory is callable -> report instance
proto.connection_made(MockStreamTransport(peer))
return factory, proto
def test_disallowed_client_is_closed(self):
factory = hblink.reportFactory(CFG)
proto = factory()
t = MockStreamTransport(('10.9.9.9', 1234)) # not in REPORT_CLIENTS
proto.connection_made(t)
self.assertTrue(t.closed)
self.assertNotIn(proto, factory.clients)
def test_connect_pushes_config_snapshot(self):
_, proto = self._client()
first = bytes(proto.transport.written).split(b'\n')[0]
evt = json.loads(first)
self.assertEqual(evt['type'], 'config')
self.assertIn('MASTER-1', evt['systems'])
self.assertEqual(evt['systems']['OBP-1']['MODE'], 'OPENBRIDGE')
def test_send_clients_emits_one_json_line(self):
factory, proto = self._client()
proto.transport.written.clear()
factory.send_clients({'type': 'ping', 'n': 1})
data = bytes(proto.transport.written)
self.assertTrue(data.endswith(b'\n'))
self.assertEqual(json.loads(data), {'type': 'ping', 'n': 1})
def test_json_systems_omits_secrets(self):
view = hblink.json_systems(CFG['SYSTEMS'])
for sysview in view.values():
self.assertNotIn('PASSPHRASE', sysview)
self.assertNotIn('SUB_ACL', sysview)
class TestBridgeStreamEvents(unittest.TestCase):
def _factory(self):
import bridge
f = bridge.bridgeReportFactory(CFG)
captured = []
f.send_clients = captured.append
return f, captured
def test_start_csv_becomes_json(self):
f, cap = self._factory()
f.send_bridgeEvent(b'GROUP VOICE,START,RX,MASTER-1,123,312000,3120001,1,3100')
self.assertEqual(cap[0], {
'type': 'stream', 'call_type': 'GROUP VOICE', 'action': 'START',
'trx': 'RX', 'system': 'MASTER-1', 'stream_id': 123, 'peer': 312000,
'src': 3120001, 'slot': 1, 'dst': 3100})
def test_end_csv_includes_duration(self):
f, cap = self._factory()
f.send_bridgeEvent('GROUP VOICE,END,TX,MASTER-1,123,312000,3120001,2,3100,4.20')
self.assertEqual(cap[0]['action'], 'END')
self.assertEqual(cap[0]['slot'], 2)
self.assertEqual(cap[0]['duration'], 4.20)
class TestTransportSend(unittest.TestCase):
def test_openbridge_send_uses_sendto_to_target(self):
obp = hblink.OPENBRIDGE('OBP-1', CFG, None)
obp.transport = MockDatagramTransport()
# 20-byte DMRD header + payload; send_system rewrites peer id and appends HMAC
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
obp.send_system(pkt)
self.assertEqual(len(obp.transport.sent), 1)
data, addr = obp.transport.sent[0]
self.assertEqual(addr, (CFG['SYSTEMS']['OBP-1']['TARGET_IP'], CFG['SYSTEMS']['OBP-1']['TARGET_PORT']))
# NETWORK_ID was written into the peer-id slot [11:15]
self.assertEqual(data[11:15], CFG['SYSTEMS']['OBP-1']['NETWORK_ID'])
def test_peer_send_master_uses_sendto(self):
peer = hblink.HBSYSTEM('REPEATER-1', CFG, None)
peer.transport = MockDatagramTransport()
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
# A logged-in peer sends call traffic to its master via sendto.
peer._stats['CONNECTION'] = 'YES'
peer.send_master(pkt)
self.assertEqual(len(peer.transport.sent), 1)
_, addr = peer.transport.sent[0]
self.assertEqual(addr, CFG['SYSTEMS']['REPEATER-1']['MASTER_SOCKADDR'])
def test_peer_send_master_drops_dmrd_when_not_connected(self):
peer = hblink.HBSYSTEM('REPEATER-1', CFG, None)
peer.transport = MockDatagramTransport()
peer._stats['CONNECTION'] = 'NO'
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
peer.send_master(pkt)
self.assertEqual(peer.transport.sent, []) # call traffic dropped
# but login/keepalive packets still go out
peer.send_master(RPTL + b'\x00\x00\x00\x01')
self.assertEqual(len(peer.transport.sent), 1)
class TestSupersessionEnd(unittest.TestCase):
"""routerHBP._end_slot_stream emits a matching END for a slot's un-terminated
stream (superseded by a new stream, or timed out) so consumers don't leak it."""
def _router(self):
import bridge
cfg = {'REPORTS': {'REPORT': True}, 'SYSTEMS': CFG['SYSTEMS']}
bridge.CONFIG = cfg
captured = []
class Rep:
def send_bridgeEvent(self, data):
captured.append(data.decode() if isinstance(data, (bytes, bytearray)) else data)
return bridge.routerHBP('MASTER-1', cfg, Rep()), captured
def _activate(self, st, ct='GROUP VOICE'):
st['RX_TYPE'] = HBPF_SLT_VHEAD # an active (non-terminated) stream
st['RX_TERMINATED'] = False
st['RX_CT'] = ct
st['RX_STREAM_ID'] = bytes_4(12345)
st['RX_PEER'] = bytes_4(312100)
st['RX_RFS'] = bytes_3(3120001)
st['RX_TGID'] = bytes_3(3100)
st['RX_START'] = 100.0
st['RX_TIME'] = 104.2
def test_emits_end_and_marks_terminated(self):
r, cap = self._router()
self._activate(r.STATUS[1])
r._end_slot_stream(1)
self.assertEqual(cap, ['GROUP VOICE,END,RX,MASTER-1,12345,312100,3120001,1,3100,4.20'])
self.assertTrue(r.STATUS[1]['RX_TERMINATED'])
# idempotent: a second call (already terminated) emits nothing more
r._end_slot_stream(1)
self.assertEqual(len(cap), 1)
def test_unit_call_type_preserved(self):
r, cap = self._router()
self._activate(r.STATUS[2], ct='UNIT VOICE')
r._end_slot_stream(2)
self.assertTrue(cap[0].startswith('UNIT VOICE,END,RX,MASTER-1,12345,'))
def test_no_event_for_idle_slot(self):
r, cap = self._router()
r._end_slot_stream(1) # slot starts terminated (RX_TERMINATED == True)
self.assertEqual(cap, [])
if __name__ == '__main__':
unittest.main()