Skip to content

Commit

Permalink
Merge pull request #41 from hostcc/fix/block-notifications-from-wrong…
Browse files Browse the repository at this point in the history
…-device

fix: Enforce host/device GUID when handling notifications/alerts
  • Loading branch information
hostcc authored Sep 2, 2024
2 parents 66d3ed4 + 7c17864 commit c8fab94
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 48 deletions.
22 changes: 14 additions & 8 deletions src/pyg90alarm/alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
G90Commands, REMOTE_PORT,
REMOTE_TARGETED_DISCOVERY_PORT,
LOCAL_TARGETED_DISCOVERY_PORT,
NOTIFICATIONS_PORT,
LOCAL_NOTIFICATIONS_HOST,
LOCAL_NOTIFICATIONS_PORT,
G90ArmDisarmTypes,
)
from .base_cmd import (G90BaseCommand, G90BaseCommandData)
Expand Down Expand Up @@ -140,11 +141,14 @@ class G90Alarm(G90DeviceNotifications):
# pylint: disable=too-many-instance-attributes,too-many-arguments
def __init__(self, host: str, port: int = REMOTE_PORT,
reset_occupancy_interval: float = 3.0,
notifications_host: str = '0.0.0.0',
notifications_port: int = NOTIFICATIONS_PORT):
super().__init__(host=notifications_host, port=notifications_port)
self._host = host
self._port = port
notifications_local_host: str = LOCAL_NOTIFICATIONS_HOST,
notifications_local_port: int = LOCAL_NOTIFICATIONS_PORT):
super().__init__(
local_host=notifications_local_host,
local_port=notifications_local_port
)
self._host: str = host
self._port: int = port
self._sensors: List[G90Sensor] = []
self._devices: List[G90Device] = []
self._notifications: Optional[G90DeviceNotifications] = None
Expand Down Expand Up @@ -336,7 +340,9 @@ async def get_host_info(self) -> G90HostInfo:
:return: Device information
"""
res = await self.command(G90Commands.GETHOSTINFO)
return G90HostInfo(*res)
info = G90HostInfo(*res)
self.device_id = info.host_guid
return info

@property
async def host_status(self) -> G90HostStatus:
Expand Down Expand Up @@ -813,7 +819,7 @@ async def _simulate_alerts_from_history(
# code as alert, as if it came from the device and its
# notifications port
self._handle_alert(
(self._host, self._notifications_port),
(self._host, self._notifications_local_port),
item.as_device_alert()
)

Expand Down
3 changes: 2 additions & 1 deletion src/pyg90alarm/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
REMOTE_PORT = 12368
REMOTE_TARGETED_DISCOVERY_PORT = 12900
LOCAL_TARGETED_DISCOVERY_PORT = 12901
NOTIFICATIONS_PORT = 12901
LOCAL_NOTIFICATIONS_HOST = '0.0.0.0'
LOCAL_NOTIFICATIONS_PORT = 12901

CMD_PAGE_SIZE = 10

Expand Down
64 changes: 55 additions & 9 deletions src/pyg90alarm/device_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
G90AlertStates,
)


_LOGGER = logging.getLogger(__name__)


Expand Down Expand Up @@ -107,12 +106,29 @@ class G90DeviceAlert: # pylint: disable=too-many-instance-attributes
class G90DeviceNotifications(DatagramProtocol):
"""
Implements support for notifications/alerts sent by alarm panel.
There is a basic check to ensure only notifications/alerts from the correct
device are processed - the check uses the host and port of the device, and
the device ID (GUID) that is set by the ancestor class that implements the
commands (e.g. :class:`G90Alarm`). The latter to work correctly needs a
command to be performed first, one that fetches device GUID and then stores
it using :attr:`.device_id` (e.g. :meth:`G90Alarm.get_host_info`).
"""
def __init__(self, port: int, host: str):
def __init__(self, local_port: int, local_host: str):
# pylint: disable=too-many-arguments
self._notification_transport: Optional[BaseTransport] = None
self._notifications_host = host
self._notifications_port = port
self._notifications_local_host = local_host
self._notifications_local_port = local_port
# Host/port of the device is configured to communicating via commands.
# Inteded to validate if notifications/alert are received from the
# correct device.
self._host: Optional[str] = None
self._port: Optional[int] = None
# Same but for device ID (GUID) - the notifications logic uses it to
# perform validation, but doesn't set it from messages received (it
# will diminish the purpose of the validation, should be done by an
# ancestor class).
self._device_id: Optional[str] = None

def _handle_notification(
self, addr: Tuple[str, int], notification: G90Notification
Expand Down Expand Up @@ -145,6 +161,15 @@ def _handle_notification(
def _handle_alert(
self, addr: Tuple[str, int], alert: G90DeviceAlert
) -> None:
# Stop processing when alert is received from the device with different
# GUID
if self.device_id and alert.device_id != self.device_id:
_LOGGER.error(
"Received alert from wrong device: expected '%s', got '%s'",
self.device_id, alert.device_id
)
return

if alert.type == G90AlertTypes.DOOR_OPEN_CLOSE:
if alert.state in (
G90AlertStates.DOOR_OPEN, G90AlertStates.DOOR_CLOSE
Expand Down Expand Up @@ -219,10 +244,17 @@ def connection_lost(self, exc: Optional[Exception]) -> None:
def datagram_received( # pylint:disable=R0911
self, data: bytes, addr: Tuple[str, int]
) -> None:

"""
Invoked from datagram is received from the device.
Invoked when datagram is received from the device.
"""
if self._host and self._host != addr[0]:
_LOGGER.error(
"Received notification/alert from wrong host '%s',"
" expected from '%s'",
addr[0], self._host
)
return

s_data = data.decode('utf-8')
if not s_data.endswith('\0'):
_LOGGER.error('Missing end marker in data')
Expand Down Expand Up @@ -304,13 +336,13 @@ async def listen(self) -> None:
loop = asyncio.get_event_loop()

_LOGGER.debug('Creating UDP endpoint for %s:%s',
self._notifications_host,
self._notifications_port)
self._notifications_local_host,
self._notifications_local_port)
(self._notification_transport,
_protocol) = await loop.create_datagram_endpoint(
lambda: self,
local_addr=(
self._notifications_host, self._notifications_port
self._notifications_local_host, self._notifications_local_port
))

@property
Expand All @@ -328,3 +360,17 @@ def close(self) -> None:
_LOGGER.debug('No longer listening for device notifications')
self._notification_transport.close()
self._notification_transport = None

@property
def device_id(self) -> Optional[str]:
"""
The ID (GUID) of the panel being communicated with thru commands.
Available when any panel command receives it from the device
(:meth:`G90Alarm.get_host_info` currently).
"""
return self._device_id

@device_id.setter
def device_id(self, device_id: str) -> None:
self._device_id = device_id
28 changes: 14 additions & 14 deletions tests/test_alarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,8 @@ async def test_sensor_event(mock_device: DeviceMock) -> None:
reset_interval = 0.5
g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
reset_occupancy_interval=reset_interval,
notifications_host=mock_device.notification_host,
notifications_port=mock_device.notification_port)
notifications_local_host=mock_device.notification_host,
notifications_local_port=mock_device.notification_port)

sensors = await g90.get_sensors()
prop_sensors = await g90.sensors
Expand Down Expand Up @@ -352,8 +352,8 @@ async def test_sensor_low_battery_event(mock_device: DeviceMock) -> None:
Tests for sensor low battery callback.
"""
g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
notifications_host=mock_device.notification_host,
notifications_port=mock_device.notification_port)
notifications_local_host=mock_device.notification_host,
notifications_local_port=mock_device.notification_port)

sensors = await g90.get_sensors()
prop_sensors = await g90.sensors
Expand Down Expand Up @@ -401,8 +401,8 @@ async def test_armdisarm_callback(mock_device: DeviceMock) -> None:
armdisarm_cb = MagicMock()
armdisarm_cb.side_effect = lambda *args: future.set_result(True)
g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
notifications_host=mock_device.notification_host,
notifications_port=mock_device.notification_port)
notifications_local_host=mock_device.notification_host,
notifications_local_port=mock_device.notification_port)
g90.armdisarm_callback = armdisarm_cb
await g90.listen_device_notifications()
await mock_device.send_next_notification()
Expand Down Expand Up @@ -434,8 +434,8 @@ async def test_door_open_close_callback(mock_device: DeviceMock) -> None:
door_open_close_cb.side_effect = lambda *args: future.set_result(True)

g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
notifications_host=mock_device.notification_host,
notifications_port=mock_device.notification_port)
notifications_local_host=mock_device.notification_host,
notifications_local_port=mock_device.notification_port)
g90.door_open_close_callback = door_open_close_cb

# Simulate two device alerts - for opening (this one) and then closing the
Expand Down Expand Up @@ -492,8 +492,8 @@ async def test_alarm_callback(mock_device: DeviceMock) -> None:
alarm_cb.side_effect = lambda *args: future.set_result(True)

g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
notifications_host=mock_device.notification_host,
notifications_port=mock_device.notification_port)
notifications_local_host=mock_device.notification_host,
notifications_local_port=mock_device.notification_port)
sensors = await g90.get_sensors()
# Set extra data for the 1st sensor
sensors[0].extra_data = 'Dummy extra data'
Expand Down Expand Up @@ -753,8 +753,8 @@ async def test_sms_alert_when_armed(mock_device: DeviceMock) -> None:
armdisarm_cb = MagicMock()
armdisarm_cb.side_effect = lambda *args: future.set_result(True)
g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
notifications_host=mock_device.notification_host,
notifications_port=mock_device.notification_port)
notifications_local_host=mock_device.notification_host,
notifications_local_port=mock_device.notification_port)
g90.armdisarm_callback = armdisarm_cb
g90.sms_alert_when_armed = True
await g90.listen_device_notifications()
Expand Down Expand Up @@ -787,8 +787,8 @@ async def test_sms_alert_when_disarmed(mock_device: DeviceMock) -> None:
armdisarm_cb = MagicMock()
armdisarm_cb.side_effect = lambda *args: future.set_result(True)
g90 = G90Alarm(host=mock_device.host, port=mock_device.port,
notifications_host=mock_device.notification_host,
notifications_port=mock_device.notification_port)
notifications_local_host=mock_device.notification_host,
notifications_local_port=mock_device.notification_port)
g90.armdisarm_callback = armdisarm_cb
g90.sms_alert_when_armed = True
await g90.listen_device_notifications()
Expand Down
Loading

0 comments on commit c8fab94

Please sign in to comment.