Skip to content

Commit

Permalink
[DA] Add protection for SensorWAN "direct" addressing scheme
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
amotl committed Jun 7, 2023
1 parent 6cead7d commit 1fa3ca5
Show file tree
Hide file tree
Showing 9 changed files with 111 additions and 6 deletions.
44 changes: 42 additions & 2 deletions doc/source/handbook/configuration/mqttkit.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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/<network>-<gateway>-<node>``
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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions etc/examples/mqttkit.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion kotori/daq/application/mqttkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)

Expand Down
6 changes: 6 additions & 0 deletions kotori/daq/strategy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# -*- coding: utf-8 -*-
# (c) 2015-2021 Andreas Motl, <[email protected]>
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):
"""
Expand Down
12 changes: 12 additions & 0 deletions kotori/daq/strategy/wan.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
# (c) 2015-2023 Andreas Motl, <[email protected]>
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):
Expand Down Expand Up @@ -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())
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion kotori/vendor/hiveeyes/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions test/settings/mqttkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
32 changes: 31 additions & 1 deletion test/test_daq_mqtt.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# (c) 2020-2021 Andreas Motl <[email protected]>
import logging
import re

import pytest
import pytest_twisted
Expand Down Expand Up @@ -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.
Expand All @@ -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?
15 changes: 14 additions & 1 deletion test/test_wan_strategy.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# (c) 2023 Andreas Motl <[email protected]>
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

Expand Down Expand Up @@ -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.
"""
Expand All @@ -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():
"""
Expand Down

0 comments on commit 1fa3ca5

Please sign in to comment.