From b0700a51685e3ad26ec1eee1700c9ee181424b57 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Wed, 7 Jun 2023 01:13:45 +0200 Subject: [PATCH] [DA] Add protection for SensorWAN "direct" addressing scheme By defining a `direct_channel_allowed_networks` setting on the application configuration, the direct access to the corresponding channel will be restricted to the specified networks/owners. --- doc/source/handbook/configuration/mqttkit.rst | 44 ++++++++++++++++++- etc/examples/mqttkit.ini | 3 ++ kotori/daq/application/mqttkit.py | 2 +- kotori/daq/strategy/__init__.py | 6 +++ kotori/daq/strategy/wan.py | 12 +++++ kotori/vendor/hiveeyes/application.py | 2 +- test/settings/mqttkit.py | 1 + test/test_daq_mqtt.py | 32 +++++++++++++- test/test_wan_strategy.py | 15 ++++++- 9 files changed, 111 insertions(+), 6 deletions(-) diff --git a/doc/source/handbook/configuration/mqttkit.rst b/doc/source/handbook/configuration/mqttkit.rst index 8b0eeb86..a50f13bc 100644 --- a/doc/source/handbook/configuration/mqttkit.rst +++ b/doc/source/handbook/configuration/mqttkit.rst @@ -51,13 +51,17 @@ attribute of the main application settings section. .. literalinclude:: ../../_static/content/etc/examples/mqttkit.ini :language: ini :linenos: - :lines: 10-27 - :emphasize-lines: 4,11-18 + :lines: 10-30 + :emphasize-lines: 4,14-21 ********** Addressing ********** + +Wide channel +============ + To successfully publish data to the platform, you should get familiar with the MQTTKit addressing scheme. We call it the »quadruple hierarchy strategy« and it is reflected on the mqtt bus topic topology. @@ -89,6 +93,36 @@ The topology identifiers are specified as: In the following examples, this topology address will be encoded into the variable ``CHANNEL``. +Direct channel +============== + +When using the :ref:`hiveeyes-arduino:sensorwan-direct-addressing` scheme of +:ref:`hiveeyes-arduino:sensorwan`, it is possible to detour from the "wide" addressing scheme, +and submit data "directly" to a channel address like ``mqttkit-1/channel/--`` +instead. + +In order to restrict access to that addressing flavour to specific networks/owners only, +you can use the ``direct_channel_allowed_networks`` configuration setting, where you can +enumerate network/owner path components, which are allowed to submit data on their +corresponding channel groups. + +.. literalinclude:: ../../_static/content/etc/examples/mqttkit.ini + :language: ini + :linenos: + :lines: 20-21 + +For all others, access will be rejected by raising an ``ChannelAccessDenied`` exception. + + +Direct device +============= + +The :ref:`hiveeyes-arduino:sensorwan-direct-addressing` scheme also allows you to address +channels by device identifiers only, also detouring from the "wide" addressing scheme. + +| An example for a corresponding channel address, identifying devices by `UUID`_, would be +| ``mqttkit-1/device/123e4567-e89b-12d3-a456-426614174000``. + ************ Sending data @@ -107,6 +141,12 @@ will be encoded into the variable ``CHANNEL``. # Publish telemetry data to MQTT topic. echo "$DATA" | mosquitto_pub -t $CHANNEL/data.json -l +When using the "direct channel" addressing scheme, those invocations would address +the same channel as in the previous example:: + + CHANNEL=mqttkit-1/channel/testdrive-foobar-42 + echo "$DATA" | mosquitto_pub -t $CHANNEL/data.json -l + ************** Receiving data diff --git a/etc/examples/mqttkit.ini b/etc/examples/mqttkit.ini index 2d12ead3..bb9d3e4b 100644 --- a/etc/examples/mqttkit.ini +++ b/etc/examples/mqttkit.ini @@ -17,6 +17,9 @@ application = kotori.daq.application.mqttkit:mqttkit_application # How often to log metrics metrics_logger_interval = 60 +# Restrict SensorWAN direct addressing to specified networks/owners. +direct_channel_allowed_networks = itest, testdrive + # [mqttkit-1:mqtt] # ; Configure individual MQTT broker for this application. # ; The option group prefix `mqttkit-1` reflects the value of diff --git a/kotori/daq/application/mqttkit.py b/kotori/daq/application/mqttkit.py index 785c488b..e8866d6f 100644 --- a/kotori/daq/application/mqttkit.py +++ b/kotori/daq/application/mqttkit.py @@ -28,7 +28,7 @@ def __init__(self, name=None, application_settings=None, global_settings=None): service = MqttInfluxGrafanaService( channel = self.channel, # Data processing strategy and graphing components - strategy=WanBusStrategy(), + strategy=WanBusStrategy(channel_settings=self.channel), graphing=GrafanaManager(settings=global_settings, channel=self.channel) ) diff --git a/kotori/daq/strategy/__init__.py b/kotori/daq/strategy/__init__.py index 6abba288..e118eb2f 100644 --- a/kotori/daq/strategy/__init__.py +++ b/kotori/daq/strategy/__init__.py @@ -1,10 +1,16 @@ # -*- coding: utf-8 -*- # (c) 2015-2021 Andreas Motl, +from munch import Munch + from kotori.daq.decoder import MessageType class StrategyBase: + def __init__(self, channel_settings=None): + channel_settings = channel_settings or Munch() + self.channel_settings = channel_settings + @staticmethod def sanitize_db_identifier(value): """ diff --git a/kotori/daq/strategy/wan.py b/kotori/daq/strategy/wan.py index c89a06eb..6a79edd0 100644 --- a/kotori/daq/strategy/wan.py +++ b/kotori/daq/strategy/wan.py @@ -2,8 +2,10 @@ # (c) 2015-2023 Andreas Motl, import re +from kotori.daq.exception import ChannelAccessDenied from kotori.daq.strategy import StrategyBase from kotori.util.common import SmartMunch +from kotori.util.configuration import read_list class WanBusStrategy(StrategyBase): @@ -72,6 +74,12 @@ def topic_to_topology(self, topic): # Try to match the per-device pattern with dashed topology encoding for topics. if address is None: + + # Decode permission setting from channel configuration object. + direct_channel_allowed_networks = None + if "direct_channel_allowed_networks" in self.channel_settings: + direct_channel_allowed_networks = read_list(self.channel_settings.direct_channel_allowed_networks) + m = self.direct_channel_matcher.match(topic) if m: address = SmartMunch(m.groupdict()) @@ -98,6 +106,10 @@ def topic_to_topology(self, topic): # dissolved, or it was propagated into the `node` slot. del address.channel + # Evaluate permissions. + if direct_channel_allowed_networks and address.network not in direct_channel_allowed_networks: + raise ChannelAccessDenied(f"Rejected access to SensorWAN network: {address.network}") + # Try to match the classic path-based WAN topic encoding scheme. if address is None: m = self.wide_channel_matcher.match(topic) diff --git a/kotori/vendor/hiveeyes/application.py b/kotori/vendor/hiveeyes/application.py index 7171f750..b8259605 100644 --- a/kotori/vendor/hiveeyes/application.py +++ b/kotori/vendor/hiveeyes/application.py @@ -311,7 +311,7 @@ def hiveeyes_boot(settings, debug=False): HiveeyesGenericGrafanaManager(settings=settings, channel=channel), HiveeyesBeehiveGrafanaManager(settings=settings, channel=channel), ], - strategy = WanBusStrategy() + strategy = WanBusStrategy(channel_settings=channel) ) rootService.registerService(service) diff --git a/test/settings/mqttkit.py b/test/settings/mqttkit.py index 6d5efc17..c3094657 100644 --- a/test/settings/mqttkit.py +++ b/test/settings/mqttkit.py @@ -40,6 +40,7 @@ class TestSettings: direct_influx_measurement_sensors = 'default_123e4567_e89b_12d3_a456_426614174000_sensors' direct_mqtt_topic_device = 'mqttkit-1/device/123e4567-e89b-12d3-a456-426614174000/data.json' direct_mqtt_topic_channel = 'mqttkit-1/channel/itest-foo-bar/data.json' + direct_mqtt_topic_channel_denied = 'mqttkit-1/channel/another-foo-bar/data.json' direct_http_path_device = '/mqttkit-1/device/123e4567-e89b-12d3-a456-426614174000/data' direct_http_path_channel = '/mqttkit-1/channel/itest-foo-bar/data' diff --git a/test/test_daq_mqtt.py b/test/test_daq_mqtt.py index 2ca27f2a..91f07fbb 100644 --- a/test/test_daq_mqtt.py +++ b/test/test_daq_mqtt.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # (c) 2020-2021 Andreas Motl import logging +import re import pytest import pytest_twisted @@ -123,7 +124,7 @@ def test_mqtt_to_influxdb_json_wan_device(machinery, device_create_influxdb, dev @pytest_twisted.inlineCallbacks @pytest.mark.mqtt @pytest.mark.device -def test_mqtt_to_influxdb_json_wan_channel(machinery, create_influxdb, reset_influxdb): +def test_mqtt_to_influxdb_json_wan_channel_success(machinery, create_influxdb, reset_influxdb): """ Run MQTT data acquisition with per-device dashed-topo addressing. @@ -146,3 +147,32 @@ def test_mqtt_to_influxdb_json_wan_channel(machinery, create_influxdb, reset_inf del record['time'] assert record == {u'humidity': 83.1, u'temperature': 42.84} yield record + + +@pytest_twisted.inlineCallbacks +@pytest.mark.mqtt +@pytest.mark.device +def test_mqtt_to_influxdb_json_wan_channel_access_denied(machinery, create_influxdb, reset_influxdb): + """ + Run MQTT data acquisition with per-device dashed-topo addressing. + + Addressing: Per-device WAN, with dashed topology decoding + Example: mqttkit-1/channel/network-gateway-node + """ + + # Submit a single measurement, without timestamp. + data = { + 'temperature': 42.84, + 'humidity': 83.1, + } + yield threads.deferToThread(mqtt_json_sensor, settings.direct_mqtt_topic_channel_denied, data) + + # Wait for some time to process the message. + yield sleep(PROCESS_DELAY_MQTT) + + # Proof that no data arrived in InfluxDB. + with pytest.raises(AssertionError) as ex: + influx_sensors.get_first_record() + assert ex.match(re.escape("No data in database: len(result) = 0")) + + # FIXME: How to find `"Rejected access to SensorWAN network: another"` within log output? diff --git a/test/test_wan_strategy.py b/test/test_wan_strategy.py index b8b5a196..66a9822f 100644 --- a/test/test_wan_strategy.py +++ b/test/test_wan_strategy.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # (c) 2023 Andreas Motl import pytest +from munch import munchify +from kotori.daq.exception import ChannelAccessDenied from kotori.daq.strategy.wan import WanBusStrategy from kotori.util.common import SmartMunch @@ -43,7 +45,7 @@ def test_wan_strategy_device_generic_success(): @pytest.mark.strategy -def test_wan_strategy_device_dashed_topo_basic(): +def test_wan_strategy_device_dashed_topo_basic_success(): """ Verify the per-device WAN topology decoding, using a dashed device identifier, which translates to the topology. """ @@ -60,6 +62,17 @@ def test_wan_strategy_device_dashed_topo_basic(): ) +@pytest.mark.strategy +def test_wan_strategy_device_dashed_topo_basic_access_denied(): + """ + Verify the per-device WAN topology decoding, using a dashed device identifier, which translates to the topology. + """ + strategy = WanBusStrategy(channel_settings=munchify({"direct_channel_allowed_networks": "foo, bar"})) + with pytest.raises(ChannelAccessDenied) as ex: + strategy.topic_to_topology("myrealm/channel/baz-qux-eui70b3d57ed005dac6/data.json") + assert ex.match("Rejected access to SensorWAN network: baz") + + @pytest.mark.strategy def test_wan_strategy_device_dashed_topo_too_few_components(): """