From 32a5b8ff95c151ac40b0829e3f2b30afae2ca6b0 Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Fri, 19 Jun 2020 11:32:58 +0200 Subject: [PATCH 01/12] Add a gitignore file to the repository root. Signed-off-by: Abel Buechner-Mihaljevic --- .gitignore | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6c5bcd14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +**/target/ +**/.idea/ +**/*.iml +**/.vscode/ +**/bin/ +**/.vertx/ +**/.factorypath + +# Eclipse files +**/.classpath +**/.project +**/.settings/ +.checkstyle + +# OS files +.DS_Store From ab61faa901839ad4a9012b595386635c4de1d45a Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Fri, 19 Jun 2020 11:41:26 +0200 Subject: [PATCH 02/12] Add readme files for the protocol gateway template and the Azure example. Signed-off-by: Abel Buechner-Mihaljevic --- protocol-gateway/README.md | 9 + .../azure-mqtt-protocol-gateway/README.md | 158 ++++++++++++++++++ .../mqtt-protocol-gateway-template/README.md | 118 +++++++++++++ .../device-via-mqtt-protocol-gw.svg | 106 ++++++++++++ 4 files changed, 391 insertions(+) create mode 100644 protocol-gateway/README.md create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/README.md create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/README.md create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/device-via-mqtt-protocol-gw.svg diff --git a/protocol-gateway/README.md b/protocol-gateway/README.md new file mode 100644 index 00000000..ab38f7d4 --- /dev/null +++ b/protocol-gateway/README.md @@ -0,0 +1,9 @@ +# Hono Protocol Gateways + +Contains [protocol gateways](https://www.eclipse.org/hono/docs/concepts/connecting-devices/#connecting-via-a-protocol-gateway) for Eclipse Hono™. + +The directory [mqtt-protocol-gateway-template](mqtt-protocol-gateway-template) provides a template for the creation of +custom MQTT protocol gateways. + +The directory [azure-mqtt-protocol-gateway](azure-mqtt-protocol-gateway) contains an example implementation of the +MQTT protocol gateway template that provides (parts of) the MQTT interface of Azure IoT Hub. diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/README.md b/protocol-gateway/azure-mqtt-protocol-gateway/README.md new file mode 100644 index 00000000..88b8cd32 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/README.md @@ -0,0 +1,158 @@ +# "Azure IoT Hub" Protocol Gateway + +This Protocol Gateway shows how to use Hono's Protocol Gateway Template to implement a production-ready protocol gateway. +The MQTT-API of "Azure IoT Hub" serves as a working example. Parts of its API are mapped to Hono's communication patterns. + +Full compatibility with the Azure IoT Hub is not a design goal of this example. It is supposed to behave similarly for +the "happy path", but cannot treat all errors or misuse in the same way as the former. + +Supported are the following types of messages: + +## Mapping of Azure IoT Hub messages to Hono messages + +**Device-to-cloud communication** + +| Azure IoT Hub message | Hono message | Limitations | +|---|---|---| +| [Device-to-cloud](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-d2c-guidance) with QoS 0 (*AT MOST ONCE*) | [Telemetry](https://www.eclipse.org/hono/docs/api/telemetry/#forward-telemetry-data) | Messages are not brokered | +| [Device-to-cloud](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-d2c-guidance) with QoS 1 (*AT LEAST ONCE*) | [Event](https://www.eclipse.org/hono/docs/api/event/#forward-event) | Messages are not brokered | + + +**Cloud-to-device communication** + +| Hono message | Azure IoT Hub message | Limitations | +|---|---|---| +| [One-way Command](https://www.eclipse.org/hono/docs/api/command-and-control/#send-a-one-way-command) | [Cloud-to-device](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-messages-c2d) | Messages are not brokered (ignores CleanSession flag) | +| [Request/Response Command](https://www.eclipse.org/hono/docs/api/command-and-control/#send-a-request-response-command) | [Direct method](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-direct-methods) | | + +## Limitations + +Not supported are the following features of Azure IoT Hub: + + * "device twins" + * file uploads + * message brokering + * "jobs" + * the back-end application API + * device authentication with client certificates + +## Device Authentication + +A Hono protocol gateway is responsible for the authentication of the devices. +This example implementation does not provide or require data storage for device credentials. +Instead, it can only be configured to use a single demo device, which must already be present in Hono's device registry (see below). +Client certificate based authentication is not implemented. + +Since there is only one device in this example implementation anyway, the credentials for the tenant's gateway client are not looked up dynamically, but are taken from the configuration. + + +## Prerequisites + +### Configuration + +The protocol gateway needs the configuration of: + +1. the AMQP adapter of a running Hono instance to connect to +2. the MQTT server +3. the Demo-Device to use. + +By default, the gateway will connect to the AMQP adapter of the [Hono Sandbox](https://www.eclipse.org/hono/sandbox/). +However, it can also be configured to connect to a local instance. +The default configuration can be found in the file `protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml` +and can be customized using [Spring Boot Configuration](https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config). + + +### Registering Devices + +The demo device to be used needs to be registered in Hono's device registry. +The [Getting started](https://www.eclipse.org/hono/getting-started/#registering-devices) guide shows how to register a device. + + +### Starting a Receiver + +Telemetry and event messages need an application that consumes the messages. +The [Getting started](https://www.eclipse.org/hono/getting-started/#starting-the-example-application) guide shows how to start the example application that receives the messages. + + +## Starting the Protocol Gateway + +Build the template project: +~~~sh +# in directory: protocol-gateway/mqtt-protocol-gateway-template/ +mvn clean install +~~~ + +and start the protocol gateway: +~~~sh +# in directory: protocol-gateway/azure-mqtt-protocol-gateway/ +mvn spring-boot:run +~~~ + + +## Enable TLS + +Azure IoT Hub only provides connections with TLS and only offers port 8883. To start the protocol gateway listening +on this port with TLS enabled and a demo certificate, run: + +~~~sh +# in directory: protocol-gateway/azure-mqtt-protocol-gateway/ +mvn clean install +java -jar target/azure-protocol-gateway-example-*-exec.jar --spring.profiles.active=ssl +~~~ +**NB** Do not forget to build the template project before, as shown above. + +With the [Eclipse Mosquitto](https://mosquitto.org/) command line client, for example, sending an event message would then look like this: + +~~~sh +# in directory: protocol-gateway/azure-mqtt-protocol-gateway +mosquitto_pub -d -h localhost -p 8883 -i '4712' -u 'demo1' -P 'demo-secret' -t "devices/4712/messages/events/" -m "hello world" -V mqttv311 --cafile target/config/hono-demo-certs-jar/trusted-certs.pem -q 1 +~~~ + +Existing hardware devices might need to be configured to accept the used certificate. + +## Example Requests + +With the [Eclipse Mosquitto](https://mosquitto.org/) command line client the requests look like the following. + +**Telemetry** + +~~~sh +mosquitto_pub -d -h localhost -i '4712' -u 'demo1' -P 'demo-secret' -t 'devices/4712/messages/events/?foo%20bar=b%5Fa%5Fz' -m "hello world" -V mqttv311 -q 0 +~~~ + +**Events** + +~~~sh +mosquitto_pub -d -h localhost -i '4712' -u 'demo1' -P 'demo-secret' -t 'devices/4712/messages/events/?foo%20bar=b%5Fa%5Fz' -m '{"alarm": 1}' -V mqttv311 -q 1 +~~~ + +### Commands + +The example application can be used to send commands. +The [Getting started](https://www.eclipse.org/hono/getting-started/#advanced-sending-commands-to-a-device) shows a walk-through example. + +**Subscribe for one-way commands** + +~~~sh +mosquitto_sub -v -h localhost -u "demo1" -P "demo-secret" -t 'devices/4712/messages/devicebound/#' -q 1 +~~~ + +**Subscribe for request-response commands** + +~~~sh +mosquitto_sub -v -h localhost -u "demo1" -P "demo-secret" -t '$iothub/methods/POST/#' -q 1 +~~~ + +When Mosquitto receives the command, in the terminal should appear something like this: +~~~sh +$iothub/methods/POST/setBrightness/?$rid=0100bba05d61-7027-4131-9a9d-30238b9ec9bb {"brightness": 87} +~~~ + +**Respond to a command** + +To send a response the ID after `rid=` can be copied and pasted into a new terminal to send a response like this: +~~~sh +export RID=0100bba05d61-7027-4131-9a9d-30238b9ec9bb +mosquitto_pub -d -h localhost -u 'demo1' -P 'demo-secret' -t "\$iothub/methods/res/200/?\$rid=$RID" -m '{"success": true}' -q 1 +~~~ +Note that the actual identifier from the command must be used. diff --git a/protocol-gateway/mqtt-protocol-gateway-template/README.md b/protocol-gateway/mqtt-protocol-gateway-template/README.md new file mode 100644 index 00000000..5cc78eb1 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/README.md @@ -0,0 +1,118 @@ +# MQTT Protocol Gateway Template + + +This project provides a template for creating MQTT +[protocol gateways](https://www.eclipse.org/hono/docs/concepts/connecting-devices/#connecting-via-a-protocol-gateway) for Eclipse Hono™. +While Hono contains an [MQTT protocol adapter](https://www.eclipse.org/hono/docs/user-guide/mqtt-adapter/), there are cases when +it cannot be used, e.g., when existing MQTT-enabled devices cannot be updated to use the supported topics of the default protocol adapter. +Other use cases for an MQTT protocol gateway are special requirements that the protocol adapter does not support, +such as custom authentication or message transformations, like compression or encryption of the payload. + +This template allows creating protocol gateways quickly and easily. It accepts messages on custom topics and sends +them to Hono's [AMQP adapter](https://www.eclipse.org/hono/docs/user-guide/amqp-adapter/). +Received commands can be published on custom topics to the device. + +The following diagram shows how two devices are connected to an MQTT protocol gateway in the backend, +which in turn is connected to Hono's AMQP protocol adapter. + +![Diagram shows devices connected to protocol gateway via MQTT, which connects to the AMQP adapter](device-via-mqtt-protocol-gw.svg) + + +## Implement a custom MQTT Protocol Gateway + +The template is provided in form of the abstract class `AbstractMqttProtocolGateway`. +The abstract methods must be implemented to handle the following events: + +1. when a device connects: authenticate the device +1. when a device subscribes: validate the topic filters +1. when a business application sends a command: + * determine the topic on which the device expects it + * select the corresponding subscription + * (optional) modify the payload +1. when a device sends a message: + * select the message type (telemetry, event, command response) + * (if command response) correlate the message to the command + * (optional) modify the payload + * (optional) specify the content type + * (optional) add "application properties" + +The abstract class is configured by its constructor parameters. +The `ClientConfigProperties` configure the connection to the AMQP protocol adapter and the `MqttProtocolGatewayConfig` +configures the protocol gateway including the MQTT server. + +**NB** When receiving commands, the AMQP message is settled (and accepted) as soon as the message has been successfully +published to the device. The implementation does not wait for an acknowledgement from the device, regardless of the +QoS with which the device has subscribed. +If the business application requires a higher delivery guarantee, it is recommended to use request-response commands. + +### Device Registration + +The template presumes that the gateway device as well as the devices connecting to it are registered in Hono's +device registry and that the gateway is authorized for the device using the _via_ property, as described on the +[concept page](https://www.eclipse.org/hono/docs/concepts/connecting-devices/#connecting-via-a-device-gateway). + +It is not necessary to provision credentials for the devices to Hono's device registry. + + +### Device Authentication + +The protocol gateway is responsible for establishing and verifying the identity of devices. +This template supports both the authentication based on the username and password provided in +an MQTT CONNECT packet and client certificate-based authentication as part of a TLS handshake for this purpose. + +For authentication with username, the abstract method _authenticateDevice_ must be implemented. +The provided credentials must be checked there and the tenant ID and device ID of the device must be returned. +Authentication with username is not executed if authentication with client certificate was successful. + +To authenticate devices that use X.509 client certificates, two methods must be overridden: one provides the +issuer certificates to be used as trust anchors for validation, and the other must determine the tenant ID and +device ID for the validated client certificate. + +To authenticate client certificates that are not based on the X.509 standard, the 'authenticateDeviceCertificate' +method must be overwritten and the entire validation and authentication process implemented. + +Authentication with client certificates is only invoked if the connection is secured with TLS. +If it fails, the authentication with username is then invoked. + + +### Correlation of Commands and Responses + +Hono's [Command & Control](https://www.eclipse.org/hono/docs/api/command-and-control) API requires that a +command response must include the correlation ID and the response ID of the command. +When using MQTT, there is no canonical way to do this. The following three approaches are conceivable: + +1. encode the parameters in the topic and have the device send them back +2. encode them into the payload and have them returned by the device +3. keep them in the protocol gateway and add them there to the response + +If the values are sent to the device (first two options), the device must support this and add the +correct data to the response in the required manner. +The 3rd approach means that the protocol gateway is stateful and has the disadvantage that the protocol gateway +can no longer simply be scaled horizontally. The template allows to use any of these strategies. + + +### Gateway Authentication + +Gateways must be registered as devices in Hono's device registry, and the corresponding credentials for authentication must be created. + +If all devices connecting to the protocol gateway belong to the same tenant, the credentials of the gateway device +can be configured in the `ClientConfigProperties`, which are passed in the constructor. +If devices of different tenants are to be connected, the credentials must be determined dynamically, as described below. + + +### Multi-tenancy + +The protocol gateway supports the handling of devices belonging to different tenants. +For this purpose, for each tenant that is supposed to use the protocol gateway, a gateway device must be registered +in the device registry and corresponding credentials must be created. + +The method _provideGatewayCredentials_ must be overwritten. This method is invoked after the (successful) authentication +of a device to provide the gateway credentials for this tenant. + +**NB** If credentials for the gateway are present in the configuration, the method _provideGatewayCredentials_ is _not_ invoked. + + +### Optional Extension Points + +The abstract base class exposes some `protected` methods that may be used to extend the behavior of the protocol gateway. +Please refer to the JavaDoc for details. diff --git a/protocol-gateway/mqtt-protocol-gateway-template/device-via-mqtt-protocol-gw.svg b/protocol-gateway/mqtt-protocol-gateway-template/device-via-mqtt-protocol-gw.svg new file mode 100644 index 00000000..ca6fc0cd --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/device-via-mqtt-protocol-gw.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + Device 1 + + + + Device 2 + + + Internet + + + + + + MQTT Protocol Gateway + + + + + + AMQP Adapter + + + MQTT + + + MQTT + + + MQTT + + + AMQP + + + From 9602eb8599340d84d4265707634a8734ff030b9c Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Fri, 19 Jun 2020 16:13:22 +0200 Subject: [PATCH 03/12] Add a template for MQTT protocol gateways and an example implementation of this template that supports the MQTT API of the Azure IoT Hub. Signed-off-by: Abel Buechner-Mihaljevic --- .../azure-mqtt-protocol-gateway/pom.xml | 270 +++++ .../azure/AzureIotHubGatewayApplication.java | 50 + .../gateway/azure/AzureIotHubMqttGateway.java | 367 +++++++ .../eclipse/hono/gateway/azure/Config.java | 83 ++ .../azure/DemoDeviceConfiguration.java | 97 ++ .../hono/gateway/azure/PropertyBag.java | 117 +++ .../eclipse/hono/gateway/azure/RequestId.java | 115 +++ .../src/main/resources/application.yml | 50 + .../azure/AzureIotHubMqttGatewayTest.java | 286 ++++++ .../hono/gateway/azure/PropertyBagTest.java | 104 ++ .../hono/gateway/azure/RequestIdTest.java | 78 ++ .../mqtt-protocol-gateway-template/pom.xml | 215 ++++ .../AbstractMqttProtocolGateway.java | 917 +++++++++++++++++ .../hono/gateway/sdk/mqtt2amqp/Command.java | 77 ++ .../sdk/mqtt2amqp/CommandSubscription.java | 95 ++ .../CommandSubscriptionsManager.java | 230 +++++ .../gateway/sdk/mqtt2amqp/Credentials.java | 58 ++ .../sdk/mqtt2amqp/MqttCommandContext.java | 138 +++ .../sdk/mqtt2amqp/MqttDownstreamContext.java | 119 +++ .../mqtt2amqp/MqttProtocolGatewayConfig.java | 134 +++ .../mqtt2amqp/X509CertificateValidator.java | 86 ++ .../downstream/CommandResponseMessage.java | 110 +++ .../downstream/DownstreamMessage.java | 89 ++ .../mqtt2amqp/downstream/EventMessage.java | 35 + .../downstream/TelemetryMessage.java | 48 + .../AbstractMqttProtocolGatewayTest.java | 925 ++++++++++++++++++ .../mqtt2amqp/ProtocolGatewayTestHelper.java | 262 +++++ .../mqtt2amqp/TestMqttProtocolGateway.java | 200 ++++ .../src/test/resources/emptyKeyStoreFile.p12 | 0 .../test/resources/emptyTrustStoreFile.pem | 0 .../src/test/resources/logback.xml | 32 + 31 files changed, 5387 insertions(+) create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/pom.xml create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubGatewayApplication.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGateway.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/DemoDeviceConfiguration.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/PropertyBag.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/RequestId.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/PropertyBagTest.java create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/RequestIdTest.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/pom.xml create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGateway.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Command.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscription.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscriptionsManager.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Credentials.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttCommandContext.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttDownstreamContext.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttProtocolGatewayConfig.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/X509CertificateValidator.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/CommandResponseMessage.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/DownstreamMessage.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/EventMessage.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/TelemetryMessage.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/ProtocolGatewayTestHelper.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/TestMqttProtocolGateway.java create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/emptyKeyStoreFile.p12 create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/emptyTrustStoreFile.pem create mode 100644 protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/logback.xml diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/pom.xml b/protocol-gateway/azure-mqtt-protocol-gateway/pom.xml new file mode 100644 index 00000000..e63b2c09 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/pom.xml @@ -0,0 +1,270 @@ + + + + + 4.0.0 + + org.eclipse.hono + azure-protocol-gateway-example + 0.0.1-SNAPSHOT + + Hono Azure IoT Protocol Gateway Example + A simple protocol gateway for connecting Azure IoT Hub compliant devices via MQTT to Eclipse Hono. + + https://www.eclipse.org/hono + 2020 + + + Eclipse Foundation + https://www.eclipse.org/ + + + + + Eclipse Public License - Version 2.0 + http://www.eclipse.org/legal/epl-2.0 + SPDX-License-Identifier: EPL-2.0 + + + + + UTF-8 + UTF-8 + + 11 + 11 + + exec + + + yyyy-MM-dd + ${maven.build.timestamp} + + 3.15.0 + 1.3.0-SNAPSHOT + 0.0.1-SNAPSHOT + 5.6.0 + 3.3.3 + 1.26 + 2.2.7.RELEASE + + + + + org.eclipse.hono + hono-mqtt-protocol-gateway + ${hono.mqtt-protocol-gateway.version} + + + org.eclipse.hono + hono-legal + ${hono.version} + + + org.eclipse.hono + hono-demo-certs + ${hono.version} + + + org.springframework.boot + spring-boot + ${spring-boot.version} + + + org.springframework.boot + spring-boot-autoconfigure + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-logging + ${spring-boot.version} + + + org.yaml + snakeyaml + ${snakeyaml.version} + runtime + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + + + + + maven-compiler-plugin + 3.8.1 + + + default-compile + compile + + compile + + + + default-testCompile + test-compile + + testCompile + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.1.0 + + all,-accessibility + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + + junit.jupiter.execution.parallel.enabled = true + junit.jupiter.execution.parallel.mode.default = same_thread + junit.jupiter.execution.parallel.mode.classes.default = concurrent + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.2 + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.1 + + + copy_legal_docs + prepare-package + + unpack-dependencies + + + hono-legal + ${project.build.outputDirectory}/META-INF + legal/** + + + + + copy_demo_certs + generate-resources + + unpack-dependencies + + + + hono-demo-certs + + ${project.build.directory}/config + + *.pem, + *.jks, + *.p12 + + true + true + true + true + + + + + + org.springframework.boot + spring-boot-maven-plugin + 2.1.11.RELEASE + + + + repackage + + + false + ${classifier.spring.boot.artifact} + hono-legal,hono-demo-certs + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.1 + + + org.eclipse.hono + hono-legal + ${hono.version} + + + com.puppycrawl.tools + checkstyle + 8.32 + + + + + checkstyle-check + verify + + check + + + + + checkstyle/default.xml + checkstyle/suppressions.xml + true + + + + + + diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubGatewayApplication.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubGatewayApplication.java new file mode 100644 index 00000000..dced1ea6 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubGatewayApplication.java @@ -0,0 +1,50 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.azure; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; + +import io.vertx.core.Vertx; + +/** + * The "Azure IoT Hub" Protocol Gateway main application class. + */ +@ComponentScan +@EnableAutoConfiguration +public class AzureIotHubGatewayApplication implements ApplicationRunner { + + private final Vertx vertx = Vertx.vertx(); + + @Autowired + private AzureIotHubMqttGateway azureIotHubMqttGateway; + + /** + * Starts the "Azure IoT Hub" Protocol Gateway application. + * + * @param args Command line arguments passed to the application. + */ + public static void main(final String[] args) { + SpringApplication.run(AzureIotHubGatewayApplication.class, args); + } + + @Override + public void run(final ApplicationArguments args) { + vertx.deployVerticle(azureIotHubMqttGateway); + } +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGateway.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGateway.java new file mode 100644 index 00000000..b61c22cc --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGateway.java @@ -0,0 +1,367 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.azure; + +import java.net.HttpURLConnection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.eclipse.hono.auth.Device; +import org.eclipse.hono.client.ClientErrorException; +import org.eclipse.hono.config.ClientConfigProperties; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.AbstractMqttProtocolGateway; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.Command; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttCommandContext; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttDownstreamContext; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttProtocolGatewayConfig; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.CommandResponseMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.DownstreamMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.EventMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.TelemetryMessage; + +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; + +/** + * A Protocol Gateway implementation that shows how production ready protocol gateways can be implemented using the base + * class {@link AbstractMqttProtocolGateway} on the example of the "Azure IoT Hub". It provides parts of the MQTT API of + * the Azure IoT Hub to show how to use the communication patterns that Hono provides. + *

+ * This is not intended to be fully compatible with Azure IoT Hub. Especially it has the following limitations: + *

    + *
  • Device Twins are not supported
  • + *
  • persistent sessions (cleanSession == 0) are not supported
  • + *
+ *

+ * Device-to-cloud + * messages are sent as events (if published with QoS 1) or telemetry messages (if published with QoS 0). + *

+ * Received one-way commands are forwarded to the device as + * + * cloud-to-device messages. Received request/response commands are forwarded as + * direct + * method messages and direct method responses are sent as command responses. + */ +public class AzureIotHubMqttGateway extends AbstractMqttProtocolGateway { + + /** + * The topic to which device-to-cloud messages are being sent. + *

+ * The topic name is: {@code devices/{device_id}/messages/events/} or + * {@code devices/{device_id}/messages/events/{property_bag}}. + * + * @see + * Azure IoT Hub Documentation: "Sending device-to-cloud messages" + */ + public static final String EVENT_TOPIC_FORMAT_STRING = "devices/%s/messages/events/"; + + /** + * The topic filter to which devices have to subscribe for receiving cloud-to-device messages. + *

+ * The topic filter is: {@code devices/{device_id}/messages/devicebound/#}. + * + * @see + * Azure IoT Hub Documentation: "Receiving cloud-to-device messages" + */ + public static final String CLOUD_TO_DEVICE_TOPIC_FILTER_FORMAT_STRING = "devices/%s/messages/devicebound/#"; + + /** + * The topic to which cloud-to-device messages are being sent. + *

+ * The topic name is: {@code devices/{device_id}/messages/devicebound/} or + * {@code devices/{device_id}/messages/devicebound/{property_bag}}. + * + * @see + * Azure IoT Hub Documentation: "Receiving cloud-to-device messages" + */ + public static final String CLOUD_TO_DEVICE_TOPIC_FORMAT_STRING = "devices/%s/messages/devicebound/"; + + /** + * The topic filter to which devices have to subscribe for receiving direct method messages. + * + * @see + * Azure IoT Hub Documentation: "Respond to a direct method" + */ + public static final String DIRECT_METHOD_TOPIC_FILTER = "$iothub/methods/POST/#"; + + /** + * The topic to which direct method messages are being sent. + *

+ * The topic name is: {@code $iothub/methods/POST/{method name}/?$rid={request id}}. + *

+ * We use the command's subject as the method name. + * + * @see + * Azure IoT Hub Documentation: "Respond to a direct method" + */ + public static final String DIRECT_METHOD_TOPIC_FORMAT_STRING = "$iothub/methods/POST/%s/?$rid=%s"; + + /** + * The prefix of the topic to which a response to a direct method is being sent. + *

+ * The topic name is: {@code $iothub/methods/res/{status}/?$rid={request id}}. + * + * @see + * Azure IoT Hub Documentation: "Respond to a direct method" + */ + public static final String DIRECT_METHOD_RESPONSE_TOPIC_PREFIX = "$iothub/methods/res/"; + + private static final int DEVICE_TO_CLOUD_SIZE_LIMIT = 256 * 1024; // 256 KB + private static final int DIRECT_METHOD_SIZE_LIMIT = 128 * 1024; // 128 KB + private static final int CLOUD_TO_DEVICE_SIZE_LIMIT = 64 * 1024; // 64 KB + + private final DemoDeviceConfiguration demoDeviceConfig; + + /** + * Creates an instance. + * + * @param amqpClientConfig The AMQP client configuration. + * @param mqttServerConfig The MQTT server configuration. + * @param demoDeviceConfig The configuration for the demo device. + * @throws NullPointerException if any of the parameters is {@code null}. + * @see AbstractMqttProtocolGateway#AbstractMqttProtocolGateway(ClientConfigProperties, MqttProtocolGatewayConfig) + * The constructor of the superclass for details. + */ + public AzureIotHubMqttGateway(final ClientConfigProperties amqpClientConfig, + final MqttProtocolGatewayConfig mqttServerConfig, + final DemoDeviceConfiguration demoDeviceConfig) { + super(amqpClientConfig, mqttServerConfig); + + Objects.requireNonNull(demoDeviceConfig); + this.demoDeviceConfig = demoDeviceConfig; + } + + /** + * {@inheritDoc} + */ + @Override + protected Future authenticateDevice(final String username, final String password, final String clientId) { + if (demoDeviceConfig.getUsername().equals(username) && demoDeviceConfig.getPassword().equals(password)) { + return Future.succeededFuture(new Device(demoDeviceConfig.getTenantId(), demoDeviceConfig.getDeviceId())); + } else { + return Future.failedFuture(String.format("Authentication of device failed [username: %s]", username)); + } + } + + /** + * {@inheritDoc} + */ + @Override + protected Future onPublishedMessage(final MqttDownstreamContext ctx) { + final DownstreamMessage result; + + final String topic = ctx.topic(); + try { + + if (isDeviceToCloudTopic(topic, ctx.authenticatedDevice().getDeviceId())) { + result = createDeviceToCloudMessage(ctx); + } else if (isDirectMethodResponseTopic(topic)) { + result = createDirectMethodResponseMessage(ctx); + } else { + throw new RuntimeException("unknown message type for topic " + topic); + } + + } catch (RuntimeException e) { + return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_BAD_REQUEST, + "published message is invalid", e)); + } + + // TODO Does Azure IoT Hub set the topic somewhere in the message? + result.addApplicationProperty("topic", topic); + + PropertyBag.decode(topic).getPropertyBagIterator() + .forEachRemaining(prop -> result.addApplicationProperty(prop.getKey(), prop.getValue())); + + return Future.succeededFuture(result); + } + + private boolean isDeviceToCloudTopic(final String topic, final String deviceId) { + return topic.startsWith(getEventTopic(deviceId)); + } + + private boolean isDirectMethodResponseTopic(final String topic) { + return topic.startsWith(DIRECT_METHOD_RESPONSE_TOPIC_PREFIX); + } + + private DownstreamMessage createDeviceToCloudMessage(final MqttDownstreamContext ctx) { + final Buffer payload = ctx.message().payload(); + if (payload.length() > DEVICE_TO_CLOUD_SIZE_LIMIT) { + throw new IllegalArgumentException( + String.format("device-to-cloud message is limited to %s KB", DEVICE_TO_CLOUD_SIZE_LIMIT)); + } + + final DownstreamMessage result; + if (MqttQoS.AT_MOST_ONCE.equals(ctx.qosLevel())) { + result = new TelemetryMessage(payload, false); + } else { + result = new EventMessage(payload); + } + + if (ctx.message().isRetain()) { + result.addApplicationProperty("x-opt-retain", true); // TODO Which value does Azure IoT Hub set here? + } + return result; + } + + private DownstreamMessage createDirectMethodResponseMessage(final MqttDownstreamContext ctx) { + + validateDirectMethodPayload(ctx.message().payload()); + + final PropertyBag propertyBag = PropertyBag.decode(ctx.topic()); + final RequestId requestId = RequestId.decode(propertyBag.getProperty("$rid")); + + final String status = ctx.topic().split("\\/")[3]; + + final DownstreamMessage result = new CommandResponseMessage(requestId.getReplyId(), + requestId.getCorrelationId(), status, ctx.message().payload()); + + result.setContentType("application/json"); + return result; + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean isTopicFilterValid(final String topicFilter, final String tenantId, final String deviceId, + final String clientId) { + + final boolean isCloudToDevice = getCloudToDeviceTopicFilter(deviceId).equals(topicFilter); + final boolean isDirectMethod = DIRECT_METHOD_TOPIC_FILTER.equals(topicFilter); + + return isCloudToDevice || isDirectMethod; + } + + /** + * {@inheritDoc} + */ + @Override + protected Command onCommandReceived(final MqttCommandContext ctx) { + + final String topic; + final String topicFilter; + if (ctx.isRequestResponseCommand()) { + validateDirectMethodPayload(ctx.getPayload()); + topic = getDirectMethodTopic(ctx); + topicFilter = DIRECT_METHOD_TOPIC_FILTER; + } else { + validateCloudToDeviceMessage(ctx); + topic = getCloudToDeviceTopic(ctx); + topicFilter = getCloudToDeviceTopicFilter(ctx.getDevice().getDeviceId()); + } + + return new Command(topic, topicFilter, ctx.getPayload()); + } + + private void validateDirectMethodPayload(final Buffer payload) { + if (payload.length() > DIRECT_METHOD_SIZE_LIMIT) { + throw new IllegalArgumentException( + String.format("direct method response is limited to %s KB", DIRECT_METHOD_SIZE_LIMIT)); + } + + if (payload.length() > 0) { + // validates that it is a JSON object + final JsonObject jsonObject = payload.toJsonObject(); + log.trace("payload is JSON with {} entries", jsonObject.size()); + } + } + + private void validateCloudToDeviceMessage(final MqttCommandContext ctx) { + if (ctx.getPayload().length() > CLOUD_TO_DEVICE_SIZE_LIMIT) { + throw new RuntimeException( + String.format("cloud-to-device message is limited to %s KB", CLOUD_TO_DEVICE_SIZE_LIMIT)); + } + } + + private String getDirectMethodTopic(final MqttCommandContext ctx) { + return getDirectMethodTopic(ctx.getSubject(), ctx.getReplyTo(), ctx.getCorrelationId()); + } + + private String getCloudToDeviceTopic(final MqttCommandContext ctx) { + + final String baseTopic = getCloudToDeviceTopic(ctx.getDevice().getDeviceId()); + + final Map properties = Optional.ofNullable(ctx.getApplicationProperties()) + .map(ApplicationProperties::getValue) + .orElse(new HashMap<>()); + + addPropertyToMap(properties, "subject", ctx.getSubject()); + + addPropertyToMap(properties, "$correlationId", ctx.getCorrelationId()); + addPropertyToMap(properties, "$messageId", ctx.getMessageId()); + + return PropertyBag.encode(baseTopic, properties); + } + + /** + * Returns the event topic for the given device id. + * + * @param deviceId The id of the device that send messages to this topic. + * @return The topic without a property bag. + */ + public static String getEventTopic(final String deviceId) { + return String.format(EVENT_TOPIC_FORMAT_STRING, deviceId); + } + + /** + * Returns the topic to which direct method messages are being sent for the given method name and request id. + * + * @param methodName The method name. This implementation uses the subject of the command message. + * @param replyToAddress The reply-to address from the command message. + * @param correlationId The correlation id from the command message. + * @return The topic. + */ + public static String getDirectMethodTopic(final String methodName, final String replyToAddress, + final Object correlationId) { + final String requestId = RequestId.encode(replyToAddress, correlationId); + + return String.format(DIRECT_METHOD_TOPIC_FORMAT_STRING, methodName, requestId); + } + + /** + * Returns the cloud-to-device topic for the given device id. + * + * @param deviceId The ID of the device to which the message is addressed. + * @return The topic without a property bag. + */ + public static String getCloudToDeviceTopic(final String deviceId) { + return String.format(CLOUD_TO_DEVICE_TOPIC_FORMAT_STRING, deviceId); + } + + /** + * Returns the cloud-to-device topic filter for the given device id. + * + * @param deviceId The ID of the device that subscribes for cloud-to-device messages. + * @return The topic filter. + */ + public static String getCloudToDeviceTopicFilter(final String deviceId) { + return String.format(CLOUD_TO_DEVICE_TOPIC_FILTER_FORMAT_STRING, deviceId); + } + + private void addPropertyToMap(final Map map, final String key, final Object value) { + if (value != null) { + map.put(key, value); + } + } + +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java new file mode 100644 index 00000000..4c2a6542 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java @@ -0,0 +1,83 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.azure; + +import java.util.List; + +import org.eclipse.hono.config.ClientConfigProperties; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttProtocolGatewayConfig; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Spring Boot configuration for the the "Azure IoT Hub" Protocol Gateway. + */ +@Configuration +public class Config { + + /** + * These are the default secure protocols in Vertx. + */ + public static final List enabledProtocols = List.of("TLSv1", "TLSv1.1", "TLSv1.2"); + + /** + * Exposes configuration properties for providing an MQTT server as a Spring bean. + *

+ * Sets the TLS protocols from {@link #enabledProtocols} as the enabled secure protocols of the MQTT server if not + * set explicitly. + * + * @return The properties. + */ + @Bean + @ConfigurationProperties(prefix = "hono.server.mqtt") + public MqttProtocolGatewayConfig mqttGatewayConfig() { + final MqttProtocolGatewayConfig mqttProtocolGatewayConfig = new MqttProtocolGatewayConfig(); + mqttProtocolGatewayConfig.setSecureProtocols(enabledProtocols); + return mqttProtocolGatewayConfig; + } + + /** + * Exposes configuration properties for accessing Hono's AMQP adapter as a Spring bean. + * + * @return The properties. + */ + @Bean + @ConfigurationProperties(prefix = "hono.client.amqp") + public ClientConfigProperties amqpClientConfig() { + return new ClientConfigProperties(); + } + + /** + * Creates a new Azure IoT Hub protocol gateway instance. + * + * @return The new instance. + */ + @Bean + public AzureIotHubMqttGateway azureIotHubMqttGateway() { + return new AzureIotHubMqttGateway(amqpClientConfig(), mqttGatewayConfig(), demoDevice()); + } + + /** + * Exposes configuration properties for a demo device as a Spring bean. + * + * @return The demo device configuration against which the authentication of a connecting device is being performed. + */ + @Bean + @ConfigurationProperties(prefix = "hono.demo.device") + public DemoDeviceConfiguration demoDevice() { + return new DemoDeviceConfiguration(); + } + +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/DemoDeviceConfiguration.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/DemoDeviceConfiguration.java new file mode 100644 index 00000000..54253285 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/DemoDeviceConfiguration.java @@ -0,0 +1,97 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.azure; + +/** + * A collection of properties to configure a static device for demonstration purposes. + */ +public class DemoDeviceConfiguration { + + private String tenantId; + private String deviceId; + private String username; + private String password; + + /** + * Gets the tenant to which the device belongs. + * + * @return The tenant id. + */ + public String getTenantId() { + return tenantId; + } + + /** + * Sets the tenant to which the device belongs. + * + * @param tenantId The tenant id. + */ + public void setTenantId(final String tenantId) { + this.tenantId = tenantId; + } + + /** + * Sets the device id. + * + * @return The device id. + */ + public String getDeviceId() { + return deviceId; + } + + /** + * Sets the device id. + * + * @param deviceId The device id. + */ + public void setDeviceId(final String deviceId) { + this.deviceId = deviceId; + } + + /** + * Gets the allowed username for the device. + * + * @return The username. + */ + public String getUsername() { + return username; + } + + /** + * Sets the allowed username for the device. + * + * @param username The username. + */ + public void setUsername(final String username) { + this.username = username; + } + + /** + * Gets the allowed password for the device. + * + * @return Sets the allowed username for the device.. + */ + public String getPassword() { + return password; + } + + /** + * Sets the allowed password for the device. + * + * @param password The password. + */ + public void setPassword(final String password) { + this.password = password; + } +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/PropertyBag.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/PropertyBag.java new file mode 100644 index 00000000..060bfc24 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/PropertyBag.java @@ -0,0 +1,117 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.hono.gateway.azure; + +import java.util.AbstractMap; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import io.netty.handler.codec.http.QueryStringDecoder; +import io.netty.handler.codec.http.QueryStringEncoder; + +/** + * A collection of methods for processing a property-bag in MQTT topics. + * + */ +public final class PropertyBag { + + private final Map> properties; + private final String topicWithoutPropertyBag; + + private PropertyBag(final String topicWithoutPropertyBag, final Map> properties) { + this.properties = properties; + this.topicWithoutPropertyBag = topicWithoutPropertyBag; + } + + /** + * Creates a property bag object from the given topic by retrieving all the properties from the + * property-bag. + * + * @param topic The topic that the message has been published to. + * @return The property bag object or {@code null} if no property-bag is set in the topic. + * @throws NullPointerException if topic is {@code null}. + */ + public static PropertyBag decode(final String topic) { + + Objects.requireNonNull(topic); + + final int index = topic.lastIndexOf("?"); + if (index > 0) { + return new PropertyBag( + topic.substring(0, index), + new QueryStringDecoder(topic.substring(index)).parameters()); + } + return new PropertyBag(topic, null); + } + + /** + * Creates a topic with the given properties as an URL-encoded property bag. + * + * @param baseTopic The topic to which the properties are appended. + * @param properties The properties to encode into the result - may be {@code null}. + * @return A topic string ending with the property bag or the base topic if no properties passed in. + * @throws NullPointerException if the base topic is {@code null}. + */ + public static String encode(final String baseTopic, final Map properties) { + Objects.requireNonNull(baseTopic); + + if (properties == null) { + return baseTopic; + } else { + final QueryStringEncoder queryStringEncoder = new QueryStringEncoder(baseTopic); + properties.forEach((k, v) -> queryStringEncoder.addParam(k, v.toString())); + return queryStringEncoder.toString(); + } + } + + /** + * Gets a property value from the property-bag. + * + * @param name The property name. + * @return The property value or {@code null} if the property is not set. + */ + public String getProperty(final String name) { + return Optional.ofNullable(properties) + .map(props -> props.get(name)) + .map(values -> values.get(0)) + .orElse(null); + } + + /** + * Gets an iterator iterating over the properties. + * + * @return The properties iterator. + */ + public Iterator> getPropertyBagIterator() { + return Optional.ofNullable(properties) + .map(props -> props.entrySet().stream() + .map(entry -> (Map.Entry) new AbstractMap.SimpleEntry<>(entry.getKey(), + entry.getValue() != null ? entry.getValue().get(0) : null)) + .iterator()) + .orElse(Collections.emptyIterator()); + } + + /** + * Returns the topic without the property-bag. + * + * @return The topic without the property-bag. + */ + public String topicWithoutPropertyBag() { + return topicWithoutPropertyBag; + } + +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/RequestId.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/RequestId.java new file mode 100644 index 00000000..72500e64 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/RequestId.java @@ -0,0 +1,115 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.azure; + +import java.util.Objects; + +/** + * This class encodes the reply-to-id and the correlation-id of a command into a single string and decodes it from a + * command response back into the two values. + *

+ * The reply-to-id as well as the correlation-id can be freely chosen by the backend application and are not restricted + * regarding the used characters. Therefore no reserved delimiters can be used here, instead fixed positions are used. + * The first two characters encode the length of the correlation-id, then the correlation-id is appended and at the end + * the reply-to-id. + *

+ * The maximal expected length is 255 characters. + */ +public class RequestId { + + /** + * The maximal length of the correlation-id. + */ + public static final int MAX_LENGTH_CORRELATION_ID_HEX = 2; + + private final String replyId; + private final String correlationId; + + private RequestId(final String replyId, final String correlationId) { + this.replyId = replyId; + this.correlationId = correlationId; + } + + /** + * Gets the reply-id that has been decoded from a request-id. + * + * @return The reply-id. + */ + public String getReplyId() { + return replyId; + } + + /** + * Gets the correlation-id that has been decoded from a request-id. + * + * @return The correlation-id. + */ + public String getCorrelationId() { + return correlationId; + } + + /** + * Decodes the given request-id back into separate reply-id and correlation-id. + * + * @param requestId The encoded request-id. + * @return The object containing reply-id and correlation-id. + * @throws IllegalArgumentException if parsing or decoding fails. + */ + public static RequestId decode(final String requestId) { + + try { + final int correlationIdEnd = Integer.parseInt(requestId.substring(0, MAX_LENGTH_CORRELATION_ID_HEX), 16) + + MAX_LENGTH_CORRELATION_ID_HEX; + final String correlationId = requestId.substring(MAX_LENGTH_CORRELATION_ID_HEX, correlationIdEnd); + final String replyId = requestId.substring(correlationIdEnd); + + return new RequestId(replyId, correlationId); + } catch (final RuntimeException e) { + throw new IllegalArgumentException("Failed to decode request-id [" + requestId + "]", e); + } + } + + /** + * Combines the reply-id (which is part of the reply-to address) with the correlation-id into a single string. + * + * @param replyTo the reply-to of the command message. + * @param correlationId the correlation-id of the command message. + * @return the encoded request-id. + * @throws NullPointerException if any of the params is {@code null}. + * @throws IllegalArgumentException if the parameters do not comply to Hono's Command and Control API or if the + * correlation-id is longer than 255 chars. + * @see #decode(String) + */ + public static String encode(final String replyTo, final Object correlationId) { + Objects.requireNonNull(replyTo); + Objects.requireNonNull(correlationId); + + if (!(correlationId instanceof String)) { + throw new IllegalArgumentException("correlation-id must be a string"); + } else { + final String correlationIdString = ((String) correlationId); + if (correlationIdString.length() > 255) { + throw new IllegalArgumentException("correlationId is too long"); + } + + final String[] replyToElements = replyTo.split("\\/"); + if (replyToElements.length <= 3) { + throw new IllegalArgumentException("reply-to address is malformed"); + } else { + return String.format("%02x%s%s", correlationIdString.length(), correlationIdString, replyToElements[3]); + } + } + } + +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml new file mode 100644 index 00000000..a18067ba --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml @@ -0,0 +1,50 @@ +# +# Copyright (c) 2020 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +# + +logging: + level: + org.eclipse.hono.gateway: TRACE + org.eclipse.hono.gateway.sdk: DEBUG + +hono: + server: + mqtt: + bindAddress: 0.0.0.0 + port: 1883 + client: + amqp: + host: hono.eclipseprojects.io + port: 5671 + tlsEnabled: true + username: gw@DEFAULT_TENANT + password: gw-secret + demo: + device: + tenantId: DEFAULT_TENANT + deviceId: 4712 + username: demo1 + password: demo-secret + + +--- + +spring: + profiles: ssl + +hono: + server: + mqtt: + bindAddress: 0.0.0.0 + port: 8883 + keyPath: target/config/hono-demo-certs-jar/example-gateway-key.pem + certPath: target/config/hono-demo-certs-jar/example-gateway-cert.pem diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java new file mode 100644 index 00000000..8a4b2584 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java @@ -0,0 +1,286 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.azure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; + +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.message.Message; +import org.eclipse.hono.auth.Device; +import org.eclipse.hono.config.ClientConfigProperties; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.Command; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttCommandContext; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttDownstreamContext; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttProtocolGatewayConfig; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.CommandResponseMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.DownstreamMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.EventMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.TelemetryMessage; +import org.eclipse.hono.util.CommandConstants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; +import io.vertx.mqtt.MqttEndpoint; +import io.vertx.mqtt.messages.MqttPublishMessage; + +/** + * Verifies behavior of {@link AzureIotHubMqttGateway}. + */ +public class AzureIotHubMqttGatewayTest { + + private static final String CORRELATION_ID = "666"; + private static final String MESSAGE_ID = "777"; + private static final String APPLICATION_PROPERTY_KEY = "foo"; + private static final String APPLICATION_PROPERTY_VALUE = "bar"; + + private static final String REPLY_ID = "XXXX"; + private static final String REPLY_TO_ADDRESS = "command_response/test-tenant/test-device/" + REPLY_ID; + private static final String REQUEST_ID = RequestId.encode(REPLY_TO_ADDRESS, CORRELATION_ID); + + private static final String TENANT_ID = "test-tenant"; + private static final String DEVICE_ID = "device1"; + private static final String CLIENT_ID = "the-client-id"; + + private static final String cloudToDeviceTopicFilter = String + .format(AzureIotHubMqttGateway.CLOUD_TO_DEVICE_TOPIC_FILTER_FORMAT_STRING, DEVICE_ID); + + private static final String directMessageTopicFilter = AzureIotHubMqttGateway.DIRECT_METHOD_TOPIC_FILTER; + + private final Device device = new Device(TENANT_ID, DEVICE_ID); + private final Message commandMessage = mock(Message.class); + + private final Buffer payload = new JsonObject().put("a-key", "a-value").toBuffer(); + private final DemoDeviceConfiguration demoDeviceConfig = new DemoDeviceConfiguration(); + + private AzureIotHubMqttGateway underTest; + + /** + * Sets up the fixture. + */ + @BeforeEach + public void setUp() { + + when(commandMessage.getBody()).thenReturn(new Data(new Binary(payload.getBytes()))); + when(commandMessage.getMessageId()).thenReturn(MESSAGE_ID); + when(commandMessage.getCorrelationId()).thenReturn(CORRELATION_ID); + final HashMap propertiesMap = new HashMap<>(); + propertiesMap.put(APPLICATION_PROPERTY_KEY, APPLICATION_PROPERTY_VALUE); + final ApplicationProperties applicationProperties = new ApplicationProperties(propertiesMap); + when(commandMessage.getApplicationProperties()).thenReturn(applicationProperties); + + demoDeviceConfig.setDeviceId(DEVICE_ID); + demoDeviceConfig.setTenantId(TENANT_ID); + demoDeviceConfig.setUsername("the-username"); + demoDeviceConfig.setPassword("super secret"); + + underTest = new AzureIotHubMqttGateway(new ClientConfigProperties(), new MqttProtocolGatewayConfig(), + demoDeviceConfig); + } + + /** + * Verifies the command object when a one-way command message is received. + */ + @Test + public void testReceiveOneWayCommand() { + + // GIVEN a command context for a one-way command + final MqttCommandContext commandContext = MqttCommandContext.fromAmqpMessage(commandMessage, device); + + // WHEN the message is received + final Command command = underTest.onCommandReceived(commandContext); + + // THEN the returned command object contains the correct topic filter and payload... + assertThat(command.getTopicFilter()).isEqualTo(cloudToDeviceTopicFilter); + assertThat(command.getPayload()).isEqualTo(payload); + + // ...AND the topic contains the expected properties + final PropertyBag propertyBag = PropertyBag.decode(command.getTopic()); + + assertThat(propertyBag.topicWithoutPropertyBag()).isEqualTo("devices/" + DEVICE_ID + "/messages/devicebound/"); + + assertThat(propertyBag.getProperty("$messageId")).isEqualTo(MESSAGE_ID); + assertThat(propertyBag.getProperty("$correlationId")).isEqualTo(CORRELATION_ID); + assertThat(propertyBag.getProperty(APPLICATION_PROPERTY_KEY)).isEqualTo(APPLICATION_PROPERTY_VALUE); + + } + + /** + * Verifies the command object when a request/response command message is received. + */ + @Test + public void testReceiveRequestResponseCommand() { + final String subject = "the-subject"; + + // GIVEN a command context for a request/response command + when(commandMessage.getReplyTo()).thenReturn(REPLY_TO_ADDRESS); + when(commandMessage.getSubject()).thenReturn(subject); + + final MqttCommandContext commandContext = MqttCommandContext.fromAmqpMessage(commandMessage, device); + + // WHEN the message is received + final Command command = underTest.onCommandReceived(commandContext); + + // THEN the returned command object contains the correct topic filter, payload and topic + assertThat(command.getTopicFilter()).isEqualTo(directMessageTopicFilter); + assertThat(command.getPayload()).isEqualTo(payload); + + assertThat(command.getTopic()).isEqualTo("$iothub/methods/POST/" + subject + "/?$rid=" + REQUEST_ID); + + } + + /** + * Verifies that authentication succeeds for the credentials in the demo-device config. + */ + @Test + public void authenticateDeviceSucceeds() { + + final Future deviceFuture = underTest.authenticateDevice(demoDeviceConfig.getUsername(), + demoDeviceConfig.getPassword(), CLIENT_ID); + + assertThat(deviceFuture.succeeded()).isTrue(); + final Device result = deviceFuture.result(); + + assertThat(result).isNotNull(); + assertThat(result.getTenantId()).isEqualTo(TENANT_ID); + assertThat(result.getDeviceId()).isEqualTo(DEVICE_ID); + + } + + /** + * Verifies that authentication fails for unknown credentials. + */ + @Test + public void authenticateDeviceFailsForWrongCredentials() { + final String user = demoDeviceConfig.getUsername(); + assertThat(underTest.authenticateDevice(user, "wrong-password", CLIENT_ID).succeeded()).isFalse(); + + final String password = demoDeviceConfig.getPassword(); + assertThat(underTest.authenticateDevice("wrong-username", password, CLIENT_ID).succeeded()).isFalse(); + } + + /** + * Verifies that MQTT messages with QoS 0 on the event topic are sent downstream as telemetry messages. + */ + @Test + public void testOnPublishedMessageForTelemetryMessage() { + + // GIVEN an MQTT message with QoS 0 + final MqttPublishMessage mqttPublishMessage = mock(MqttPublishMessage.class); + when(mqttPublishMessage.payload()).thenReturn(payload); + when(mqttPublishMessage.topicName()).thenReturn(AzureIotHubMqttGateway.getEventTopic(DEVICE_ID)); + when(mqttPublishMessage.qosLevel()).thenReturn(MqttQoS.AT_MOST_ONCE); + + final MqttDownstreamContext downstreamContext = MqttDownstreamContext.fromPublishPacket(mqttPublishMessage, + mock(MqttEndpoint.class), device); + + // WHEN the message is received + final Future messageFuture = underTest.onPublishedMessage(downstreamContext); + + // THEN a telemetry message with the payload is returned + assertThat(messageFuture.succeeded()).isTrue(); + final DownstreamMessage result = messageFuture.result(); + + assertThat(result).isInstanceOfAny(TelemetryMessage.class); + assertThat(result.getPayload()).isEqualTo(payload.getBytes()); + + } + + /** + * Verifies that MQTT messages with QoS 1 on the event topic are sent downstream as event messages. + */ + @Test + public void testOnPublishedMessageForEventMessage() { + + // GIVEN an MQTT message with QoS 1 + final MqttPublishMessage mqttPublishMessage = mock(MqttPublishMessage.class); + when(mqttPublishMessage.payload()).thenReturn(payload); + when(mqttPublishMessage.topicName()).thenReturn(AzureIotHubMqttGateway.getEventTopic(DEVICE_ID)); + when(mqttPublishMessage.qosLevel()).thenReturn(MqttQoS.AT_LEAST_ONCE); + + final MqttDownstreamContext downstreamContext = MqttDownstreamContext.fromPublishPacket(mqttPublishMessage, + mock(MqttEndpoint.class), device); + + // WHEN the message is received + final Future messageFuture = underTest.onPublishedMessage(downstreamContext); + + // THEN an event message with the payload is returned + assertThat(messageFuture.succeeded()).isTrue(); + final DownstreamMessage result = messageFuture.result(); + + assertThat(result).isInstanceOfAny(EventMessage.class); + assertThat(result.getPayload()).isEqualTo(payload.getBytes()); + + } + + /** + * Verifies that MQTT messages on the direct method response topic are sent downstream as command response messages. + */ + @Test + public void testOnPublishedMessageForCommandResponse() { + final int status = 200; + + // GIVEN an MQTT message with the direct method response topic + final MqttPublishMessage mqttPublishMessage = mock(MqttPublishMessage.class); + when(mqttPublishMessage.payload()).thenReturn(payload); + when(mqttPublishMessage.qosLevel()).thenReturn(MqttQoS.AT_LEAST_ONCE); + when(mqttPublishMessage.topicName()).thenReturn(AzureIotHubMqttGateway.DIRECT_METHOD_RESPONSE_TOPIC_PREFIX + + status + "/?$rid=" + REQUEST_ID); + + final MqttDownstreamContext downstreamContext = MqttDownstreamContext.fromPublishPacket(mqttPublishMessage, + mock(MqttEndpoint.class), device); + + // WHEN the message is received + final Future messageFuture = underTest.onPublishedMessage(downstreamContext); + + // THEN a command response message with the payload is returned... + assertThat(messageFuture.succeeded()).isTrue(); + final DownstreamMessage result = messageFuture.result(); + + assertThat(result).isInstanceOfAny(CommandResponseMessage.class); + assertThat(result.getPayload()).isEqualTo(payload.getBytes()); + + // ...AND its parameters are set correctly + final CommandResponseMessage responseMessage = (CommandResponseMessage) result; + assertThat(responseMessage.getCorrelationId()).isEqualTo(CORRELATION_ID); + assertThat(responseMessage.getStatus()).isEqualTo(status); + assertThat(responseMessage.getTargetAddress(TENANT_ID, DEVICE_ID)).isEqualTo(String.format("%s/%s/%s/%s", + CommandConstants.NORTHBOUND_COMMAND_RESPONSE_ENDPOINT, TENANT_ID, DEVICE_ID, REPLY_ID)); + assertThat(responseMessage.getContentType()).isEqualTo("application/json"); + } + + /** + * Verifies that the topic filters for cloud-to-device messages and for direct method responses are validated + * successfully and other topic filters fail. + */ + @Test + public void isTopicFilterValid() { + + assertThat(underTest.isTopicFilterValid(cloudToDeviceTopicFilter, null, DEVICE_ID, null)).isTrue(); + assertThat(underTest.isTopicFilterValid(directMessageTopicFilter, null, null, null)).isTrue(); + + final String unknownTopicFilter = "foo/#"; + assertThat(underTest.isTopicFilterValid(unknownTopicFilter, null, DEVICE_ID, null)).isFalse(); + } + +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/PropertyBagTest.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/PropertyBagTest.java new file mode 100644 index 00000000..56216561 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/PropertyBagTest.java @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2019, 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.hono.gateway.azure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * Verifies behavior of {@link PropertyBag}. + * + */ +public class PropertyBagTest { + + private static final String TOPIC_WITHOUT_PROPERTY_BAG = "devices/4712/messages/events/"; + private static final String KEY1 = "a"; + private static final String VALUE1 = "b"; + private static final String ENCODED1 = "a=b"; + private static final String KEY2 = "foo bar"; + private static final String VALUE2 = "b/a/z"; + private static final String ENCODED2 = "foo%20bar=b%2Fa%2Fz"; + private static final String ENCODED_TOPIC = TOPIC_WITHOUT_PROPERTY_BAG + "?" + ENCODED1 + "&" + ENCODED2; + + /** + * Verifies that properties are set in the property-bag of the message's topic. + */ + @Test + public void testDecodePropertyBag() { + final PropertyBag propertyBag = PropertyBag.decode(ENCODED_TOPIC); + assertThat((Object) propertyBag.topicWithoutPropertyBag()).isEqualTo(TOPIC_WITHOUT_PROPERTY_BAG); + assertThat((Object) propertyBag.getProperty(KEY1)).isEqualTo(VALUE1); + assertThat((Object) propertyBag.getProperty(KEY2)).isEqualTo(VALUE2); + } + + /** + * Verifies that the property-bag is trimmed from a topic string and the rest is returned. + */ + @Test + public void testDecodeTopicWithoutPropertyBag() { + assertThat((Object) PropertyBag.decode(TOPIC_WITHOUT_PROPERTY_BAG).topicWithoutPropertyBag()) + .isEqualTo(TOPIC_WITHOUT_PROPERTY_BAG); + } + + /** + * Verifies that getPropertiesIterator returns an iterator with the expected entries. + */ + @Test + public void testGetPropertyBagIterator() { + final PropertyBag propertyBag = PropertyBag.decode(ENCODED_TOPIC); + assertThat(propertyBag).isNotNull(); + final Iterator> propertiesIterator = propertyBag.getPropertyBagIterator(); + final Map tmpMap = new HashMap<>(); + propertiesIterator.forEachRemaining((entry) -> tmpMap.put(entry.getKey(), entry.getValue())); + assertThat((Object) tmpMap.size()).isEqualTo(2); + assertThat((Object) tmpMap.get(KEY1)).isEqualTo(VALUE1); + assertThat((Object) tmpMap.get(KEY2)).isEqualTo(VALUE2); + } + + /** + * Verifies that properties get encoded into the topic. + */ + @Test + public void testEncode() { + final String topicWithPropertyBag = PropertyBag.encode(TOPIC_WITHOUT_PROPERTY_BAG, + Map.of(KEY1, VALUE1, KEY2, VALUE2)); + + // the order of the properties might differ + assertThat(topicWithPropertyBag).startsWith(TOPIC_WITHOUT_PROPERTY_BAG + "?"); + assertThat(topicWithPropertyBag).contains(ENCODED1); + assertThat(topicWithPropertyBag).contains(ENCODED2); + assertThat(topicWithPropertyBag).contains("&"); + assertThat(topicWithPropertyBag).hasSize(53); + } + + /** + * Verifies that the encoded topic can be decoded with the expected results. + */ + @Test + public void testDecodeEncodedTopic() { + final String topicWithPropertyBag = PropertyBag.encode(TOPIC_WITHOUT_PROPERTY_BAG, + Map.of(KEY1, VALUE1, KEY2, VALUE2)); + + final PropertyBag propertyBag = PropertyBag.decode(topicWithPropertyBag); + assertThat((Object) propertyBag.topicWithoutPropertyBag()).isEqualTo(TOPIC_WITHOUT_PROPERTY_BAG); + assertThat((Object) propertyBag.getProperty(KEY2)).isEqualTo(VALUE2); + assertThat((Object) propertyBag.getProperty(KEY1)).isEqualTo(VALUE1); + } + +} diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/RequestIdTest.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/RequestIdTest.java new file mode 100644 index 00000000..595c037e --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/RequestIdTest.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.azure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the behavior of {@link RequestId}. + */ +public class RequestIdTest { + + private static final String REPLY_ID = "999"; + private static final String CORRELATION_ID = "888"; + private static final String REQUEST_ID = "03888999"; + private static final int MAX_LENGTH = 255; + + /** + * Verifies that encoding a correlation id and reply id (from a reply-to address) results in the expected request + * id. + */ + @Test + public void testEncode() { + final String replyTo = "command_response/test-tenant/test-device/" + REPLY_ID; + + assertThat(RequestId.encode(replyTo, CORRELATION_ID)).isEqualTo("03888999"); + + } + + /** + * Verifies that decoding the request id results in the expected reply id and correlation id. + */ + @Test + public void testDecode() { + + final RequestId requestId = RequestId.decode(REQUEST_ID); + assertThat(requestId.getCorrelationId()).isEqualTo(CORRELATION_ID); + assertThat(requestId.getReplyId()).isEqualTo(REPLY_ID); + } + + /** + * Verifies that trying to encode too long correlation ids result in the expected exception. + */ + @Test + public void testMaxCorrelationIdLength() { + + // GIVEN a correlation id that is longer than the max length allowed ... + final char[] tooManyChars = new char[MAX_LENGTH + 1]; + Arrays.fill(tooManyChars, '8'); + final String tooLongCorrelationId = new String(tooManyChars); + + // ... AND a correlation id that is 1 character shorter + final char[] maxChars = Arrays.copyOf(tooManyChars, MAX_LENGTH); + final String maxCorrelationId = new String(maxChars); + + // WHEN encoding the short correlation id THEN no exception is thrown ... + RequestId.encode("command_response/test-tenant/test-device/" + REPLY_ID, maxCorrelationId); + + // ... WHEN encoding longer correlation id THEN the expected exception is thrown + assertThrows(IllegalArgumentException.class, + () -> RequestId.encode("command_response/test-tenant/test-device/" + REPLY_ID, tooLongCorrelationId)); + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/pom.xml b/protocol-gateway/mqtt-protocol-gateway-template/pom.xml new file mode 100644 index 00000000..b06490b7 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/pom.xml @@ -0,0 +1,215 @@ + + + + + 4.0.0 + + org.eclipse.hono + hono-mqtt-protocol-gateway + 0.0.1-SNAPSHOT + bundle + + Hono MQTT Protocol Gateway Template + A template for MQTT protocol gateways for Hono. + https://www.eclipse.org/hono + 2020 + + + Eclipse Foundation + https://www.eclipse.org/ + + + + + Eclipse Public License - Version 2.0 + http://www.eclipse.org/legal/epl-2.0 + SPDX-License-Identifier: EPL-2.0 + + + + + UTF-8 + UTF-8 + 1.8 + + + yyyy-MM-dd + ${maven.build.timestamp} + + 3.15.0 + 1.3.0-M2 + 5.6.0 + 3.3.3 + 3.9.1 + + + + + org.eclipse.hono + hono-client + ${hono.version} + + + org.eclipse.hono + hono-legal + ${hono.version} + + + io.vertx + vertx-mqtt + ${vertx.version} + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + io.vertx + vertx-junit5 + ${vertx.version} + test + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.1.0 + + ${java.level} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${java.level} + ${java.level} + UTF-8 + + + + org.codehaus.mojo + animal-sniffer-maven-plugin + 1.18 + + + verify_java8_compatibility + test + + check + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.1 + + + copy_legal_docs + prepare-package + + unpack-dependencies + + + hono-legal + ${project.build.outputDirectory}/META-INF + legal/** + + + + + + org.apache.felix + maven-bundle-plugin + 3.5.0 + true + + + + + META-INF=${project.build.outputDirectory}/META-INF + + + {local-packages} + + + osgi.ee;filter:="(&(osgi.ee=JavaSE)(version=${java.level}))" + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.1 + + + org.eclipse.hono + hono-legal + ${hono.version} + + + com.puppycrawl.tools + checkstyle + 8.32 + + + + + checkstyle-check + verify + + check + + + + + checkstyle/default.xml + checkstyle/suppressions.xml + true + + + + + + diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGateway.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGateway.java new file mode 100644 index 00000000..e628c655 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGateway.java @@ -0,0 +1,917 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.net.HttpURLConnection; +import java.security.cert.Certificate; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.net.ssl.SSLPeerUnverifiedException; + +import org.apache.qpid.proton.message.Message; +import org.eclipse.hono.auth.Device; +import org.eclipse.hono.client.ClientErrorException; +import org.eclipse.hono.client.HonoConnection; +import org.eclipse.hono.client.MessageConsumer; +import org.eclipse.hono.client.ServiceInvocationException; +import org.eclipse.hono.client.device.amqp.AmqpAdapterClientFactory; +import org.eclipse.hono.config.ClientConfigProperties; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.CommandResponseMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.DownstreamMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.EventMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.TelemetryMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.AbstractVerticle; +import io.vertx.core.CompositeFuture; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.http.ClientAuth; +import io.vertx.core.net.KeyCertOptions; +import io.vertx.core.net.NetServerOptions; +import io.vertx.core.net.TrustOptions; +import io.vertx.mqtt.MqttAuth; +import io.vertx.mqtt.MqttEndpoint; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.MqttServerOptions; +import io.vertx.mqtt.messages.MqttSubscribeMessage; +import io.vertx.mqtt.messages.MqttUnsubscribeMessage; +import io.vertx.proton.ProtonDelivery; + +/** + * Base class for implementing a protocol gateway that connects to a Hono AMQP adapter and provides a custom MQTT server + * for devices to connect to. + *

+ * This implementation does not support MQTT QoS 2; when a device requests QoS 2 in its SUBSCRIBE message, only + * QoS 1 is granted. + *

+ * When receiving commands, the AMQP message is settled with the outcome accepted as soon as the message has + * been successfully published to the device. The implementation does not wait for an acknowledgement from the device, + * regardless of the QoS with which the device has subscribed. + */ +public abstract class AbstractMqttProtocolGateway extends AbstractVerticle { + + /** + * A logger to be shared with subclasses. + */ + protected final Logger log = LoggerFactory.getLogger(getClass()); + + private final ClientConfigProperties amqpClientConfig; + private final MqttProtocolGatewayConfig mqttGatewayConfig; + private final Map clientFactoryPerTenant = new HashMap<>(); + + private MqttServer server; + + /** + * Creates an instance. + *

+ * The AMQP client configuration needs to contain the properties that are required to connect to the Hono AMQP + * adapter. If it contains a username and password, those are used to authenticate the amqp client with. Otherwise + * {@link #provideGatewayCredentials(String)} needs to be overridden in order to dynamically resolve credentials for + * the tenant of a device request ("multi-tenant mode"). + * + * @param amqpClientConfig The AMQP client configuration. + * @param mqttGatewayConfig The configuration of the protocol gateway. + * @throws NullPointerException if any of the parameters is {@code null}. + * @see ClientConfigProperties#setTlsEnabled(boolean) + * @see ClientConfigProperties#setTrustStorePath(String) + */ + public AbstractMqttProtocolGateway(final ClientConfigProperties amqpClientConfig, + final MqttProtocolGatewayConfig mqttGatewayConfig) { + Objects.requireNonNull(amqpClientConfig); + Objects.requireNonNull(mqttGatewayConfig); + + this.amqpClientConfig = amqpClientConfig; + this.mqttGatewayConfig = mqttGatewayConfig; + } + + /** + * Authenticates a device that has provided the specified credentials in its CONNECT packet. This method is not + * invoked if the client certificate-based authentication was already successful. + *

+ * Implementations must return a (succeeded) future with the authenticated device if authentication was + * successful or a failed future otherwise. {@code Null} must never be returned. + * + * @param username The username. + * @param password The password. + * @param clientId The client id. + * @return A future indicating the outcome of the operation. + */ + protected abstract Future authenticateDevice(String username, String password, String clientId); + + /** + * Validates the topic filter that a device sent in its subscription message. Additional information is provided + * with the parameters that can be used for validation. + * + * @param topicFilter the topic filter provided by the device. + * @param tenantId the tenant id of the authenticated device. + * @param deviceId the device id of the authenticated device. + * @param clientId the MQTT client id of the device. + * @return {@code true} if the topic filter is valid. + */ + protected abstract boolean isTopicFilterValid(String topicFilter, String tenantId, String deviceId, + String clientId); + + /** + * This method is called when a message has been published by a device via MQTT. It prepares the data to be uploaded + * to Hono. + *

+ * Subclasses determine the message type by returning one of the subclasses of {@link DownstreamMessage}. + * + * @param ctx The context in which the MQTT message has been published. + * @return A future indicating the outcome of the operation. If an error occurs, a failed future is returned, but + * never {@code null}. If the failure has been caused by the device that published the message, the (failed) + * future contains a {@link ClientErrorException}. + */ + protected abstract Future onPublishedMessage(MqttDownstreamContext ctx); + + /** + * This method is called when a command message that has been received from Hono. It prepares the data to be + * published to the device via MQTT. + *

+ * If the implementation throws an exception, the AMQP command message will be released. + * + * @param ctx The context in which the command has been received. + * @return The command to be published to the device - must not be {@code null}. + */ + protected abstract Command onCommandReceived(MqttCommandContext ctx); + + /** + * Gets credentials for authentication against the AMQP adapter to which this protocol gateway connects. If username + * and password are specified in the AMQP client configuration of this gateway, then these are used and this method + * is not invoked. + *

+ * Subclasses should overwrite this method to resolve the credentials for the given client. + *

+ * This default implementation returns a failed future because it is only called if no configuration with username + * and password is provided and it is not overwritten by an alternative implementation. + *

+ * The method must never return {@code null}. + * + * @param tenantId The tenant for which a connection is required (from the device authentication). + * @return A future indicating the outcome of the operation. + * @see ClientConfigProperties#setUsername(String) + * @see ClientConfigProperties#setPassword(String) + */ + protected Future provideGatewayCredentials(final String tenantId) { + return Future.failedFuture("credentials of the protocol gateway not found in the provided configuration."); + } + + /** + * Invoked when a message has been forwarded downstream successfully. + *

+ * This default implementation does nothing. + *

+ * Subclasses should override this method in order to e.g. update metrics counters. + * + * @param ctx The context in which the MQTT message has been published. + */ + protected void onMessageSent(final MqttDownstreamContext ctx) { + } + + /** + * Invoked when a message could not be forwarded downstream. + *

+ * This method will only be invoked if the failure to forward the message has not been caused by the device that + * published the message. + *

+ * This default implementation does nothing. + *

+ * Subclasses should override this method in order to e.g. update metrics counters. + * + * @param ctx The context in which the MQTT message has been published. + */ + protected void onMessageUndeliverable(final MqttDownstreamContext ctx) { + } + + /** + * Invoked when a message has been sent to the device successfully. + *

+ * This default implementation does nothing. + *

+ * Subclasses should override this method in order to e.g. update metrics counters. + * + * @param command The received command message. + * @param subscription The corresponding subscription. + */ + protected void onCommandPublished(final Message command, final CommandSubscription subscription) { + } + + /** + * Invoked before the connection with a device is closed. + *

+ * Subclasses should override this method in order to release any device specific resources. + *

+ * This default implementation does nothing. + * + * @param endpoint The connection to be closed. + */ + protected void onDeviceConnectionClose(final MqttEndpoint endpoint) { + } + + /** + * Authenticates a device using its TLS client certificate. This method is only invoked if the device establishes a + * connection with TLS and presents a client certificate. + *

+ * If authentication fails, the username/password based authentication + * ({@link #authenticateDevice(String, String, String)}) will be invoked afterwards. + *

+ * To authenticate devices using client certificates, subclasses must either (a) override methods + * {@link #getTrustAnchors(List)} and {@link #authenticateClientCertificate(X509Certificate)} if only X.509 + * certificates are used, or (b) override this method if other certificate types are to be used. + *

+ * This default implementation only validates X.509 certificates. It performs the following steps if the previous + * steps were successful: + *

    + *
  1. invoke {@link #getTrustAnchors(List)}
  2. + *
  3. validate the given certificate chain against the trust anchors
  4. + *
  5. invoke {@link #authenticateClientCertificate(X509Certificate)}
  6. + *
+ * If one of the steps fails (which they do, unless the above methods are overridden in a subclass), this method + * returns a failed future, which causes username/password based authentication to be invoked. + * + * @param path The certificate path from the TLS session with the client certificate first - not {@code null}. + * @return A future indicating the outcome of the operation. The future will succeed with the device data belonging + * to the authentication or it will fail with a failure message indicating the cause of the failure. + * {@code Null} must never be returned. + * + * @see #getTrustAnchors(List) + * @see #authenticateClientCertificate(X509Certificate) + */ + protected Future authenticateDeviceCertificate(final Certificate[] path) { + + final List certificates = Arrays.stream(path) + .filter(cert -> cert instanceof X509Certificate) + .map(cert -> ((X509Certificate) cert)) + .collect(Collectors.toList()); + + final X509CertificateValidator validator = new X509CertificateValidator(); + return getTrustAnchors(certificates) + .compose(trustAnchors -> validator.validate(certificates, trustAnchors)) + .compose(ok -> authenticateClientCertificate(certificates.get(0))); + } + + /** + * Returns the trust anchors to be used to validate the X.509 client certificate of a device. + *

+ * Subclasses should override this method to provide trust anchors against which the device certificate can be + * validated. + *

+ * To authenticate devices using client certificates, subclasses must either (a) override this method and + * {@link #authenticateClientCertificate(X509Certificate)} if only X.509 certificates are used, or (b) override + * method {@link #authenticateDeviceCertificate(Certificate[])} if other certificate types are to be used. + *

+ * This default implementation always returns a failed future because there are no default trust anchors. + * + * @param certificates The certificate chain to be validated, which - depending on the actual implementation - may + * be necessary to select the relevant trust anchors. + * @return A future indicating the outcome of the operation. The future will succeed with the trust anchors to be + * used for the validation or it will fail with a failure message indicating the cause of the failure. + * {@code Null} must never be returned. + * + * @see #authenticateDeviceCertificate(Certificate[]) + * @see #authenticateClientCertificate(X509Certificate) + */ + protected Future> getTrustAnchors(final List certificates) { + return Future.failedFuture("Client certificate can not be validated: no trust anchors provided"); + } + + /** + * Authenticates the X.509 client certificate and returns the authenticated device. + *

+ * Subclasses should override this method to check if the given certificate identifies is a known and authorized + * device and to retrieve the tenant id and the device id for it. + *

+ * To authenticate devices using client certificates, subclasses must either (a) override this method and + * {@link #getTrustAnchors(List)} if only X.509 certificates are used, or (b) override method + * {@link #authenticateDeviceCertificate(Certificate[])} if other certificate types are to be used. + *

+ * This default implementation always returns a failed future. + * + * @param deviceCertificate The already validated client certificate. + * @return A future indicating the outcome of the operation. The future will succeed with the authenticated device + * or it will fail with a failure message indicating the cause of the failure. {@code Null} must never be + * returned. + * + * @see #authenticateDeviceCertificate(Certificate[]) + * @see #getTrustAnchors(List) + */ + protected Future authenticateClientCertificate(final X509Certificate deviceCertificate) { + return Future.failedFuture("Cannot establish device identity"); + } + + /** + * Invoked when a device sends its CONNECT packet. + *

+ * Authenticates the device, connects the gateway to Hono's AMQP adapter and registers handlers for processing + * messages published by the client. + * + * @param endpoint The MQTT endpoint representing the client. + * @throws NullPointerException if the endpoint is {@code null}. + */ + final void handleEndpointConnection(final MqttEndpoint endpoint) { + + Objects.requireNonNull(endpoint); + + log.debug("connection request from client [client-id: {}]", endpoint.clientIdentifier()); + + if (!endpoint.isCleanSession()) { + log.debug("ignoring client's intent to resume existing session"); + } + if (endpoint.will() != null) { + log.debug("ignoring client's last will"); + } + + final Future authAttempt = tryAuthenticationWithClientCertificate(endpoint) + .recover(ex -> authenticateWithUsernameAndPassword(endpoint)) + .compose(authenticateDevice -> (authenticateDevice == null) + ? Future.failedFuture("device authentication failed") + : Future.succeededFuture(authenticateDevice)); + + authAttempt + .compose(this::connectGatewayToAmqpAdapter) + .onComplete(result -> { + if (result.succeeded()) { + registerHandlers(endpoint, authAttempt.result()); + log.debug("connection accepted from {}", authAttempt.result().toString()); + endpoint.accept(false); // we do not maintain session state + } else { + final MqttConnectReturnCode returnCode; + if (authAttempt.failed()) { + log.debug("connection request from client [clientId: {}] rejected, authentication failed", + endpoint.clientIdentifier(), authAttempt.cause()); + returnCode = MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED; + } else { + log.debug( + "connection request from client [clientId: {}] rejected, connection to backend failed", + endpoint.clientIdentifier(), result.cause()); + returnCode = MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE; + } + + endpoint.reject(returnCode); + } + }); + } + + private Future tryAuthenticationWithClientCertificate(final MqttEndpoint endpoint) { + if (endpoint.isSsl()) { + try { + final Certificate[] path = endpoint.sslSession().getPeerCertificates(); + if (path != null && path.length > 0) { + final Future authAttempt = authenticateDeviceCertificate(path); + log.debug("authentication with client certificate: {}.", + (authAttempt.succeeded()) ? "succeeded" : "failed"); + return authAttempt; + } + } catch (RuntimeException | SSLPeerUnverifiedException e) { + log.debug("could not retrieve client certificate from device endpoint: {}", e.getMessage()); + } + } + return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_UNAUTHORIZED)); + } + + private Future authenticateWithUsernameAndPassword(final MqttEndpoint endpoint) { + final MqttAuth auth = endpoint.auth(); + if (auth == null || auth.getUsername() == null || auth.getPassword() == null) { + return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_UNAUTHORIZED, + "device did not provide credentials in CONNECT packet")); + } else { + final Future authenticatedDevice = authenticateDevice(auth.getUsername(), auth.getPassword(), + endpoint.clientIdentifier()); + if (authenticatedDevice == null) { + return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_INTERNAL_ERROR)); + } else { + log.debug("authentication with username/password {}.", + (authenticatedDevice.succeeded()) ? "succeeded" : "failed"); + return authenticatedDevice; + } + } + } + + private Future connectGatewayToAmqpAdapter(final Device authenticatedDevice) { + + final String tenantId = authenticatedDevice.getTenantId(); + + if (amqpClientConfig.getUsername() != null && amqpClientConfig.getPassword() != null) { + return connectGatewayToAmqpAdapter(tenantId, amqpClientConfig); + } else { + return provideGatewayCredentials(tenantId) + .compose(credentials -> { + final ClientConfigProperties tenantConfig = new ClientConfigProperties(amqpClientConfig); + tenantConfig.setUsername(credentials.getUsername()); + tenantConfig.setPassword(credentials.getPassword()); + + return connectGatewayToAmqpAdapter(tenantId, tenantConfig); + }); + } + } + + private Future connectGatewayToAmqpAdapter(final String tenantId, final ClientConfigProperties clientConfig) { + + final AmqpAdapterClientFactory factory = clientFactoryPerTenant + .computeIfAbsent(tenantId, key -> createTenantClientFactory(key, clientConfig)); + + return factory.connect() // returns successfully if already connected + .map(con -> null); + } + + /** + * Returns a new {@link AmqpAdapterClientFactory} with a new AMQP connection for the given tenant. + *

+ * This method is only visible for testing purposes. + * + * @param tenantId The tenant to be connected. + * @param clientConfig The client properties to use for the connection. + * @return The factory. Note that the underlying AMQP connection will not be established until + * {@link AmqpAdapterClientFactory#connect()} is invoked. + */ + AmqpAdapterClientFactory createTenantClientFactory(final String tenantId, + final ClientConfigProperties clientConfig) { + final HonoConnection connection = HonoConnection.newConnection(vertx, clientConfig); + return AmqpAdapterClientFactory.create(connection, tenantId); + } + + private void registerHandlers(final MqttEndpoint endpoint, final Device authenticatedDevice) { + + endpoint.publishHandler( + message -> handlePublishedMessage( + MqttDownstreamContext.fromPublishPacket(message, endpoint, authenticatedDevice))); + + final CommandSubscriptionsManager cmdSubscriptionsManager = createCommandHandler(vertx); + endpoint.closeHandler(v -> close(endpoint, cmdSubscriptionsManager)); + endpoint.publishAcknowledgeHandler(cmdSubscriptionsManager::handlePubAck); + endpoint.subscribeHandler(msg -> onSubscribe(endpoint, authenticatedDevice, msg, cmdSubscriptionsManager)); + endpoint.unsubscribeHandler(msg -> onUnsubscribe(endpoint, authenticatedDevice, msg, cmdSubscriptionsManager)); + + } + + private void close(final MqttEndpoint endpoint, final CommandSubscriptionsManager cmdSubscriptionsManager) { + onDeviceConnectionClose(endpoint); + cmdSubscriptionsManager.removeAllSubscriptions(); + if (endpoint.isConnected()) { + log.debug("closing connection with client [client ID: {}]", endpoint.clientIdentifier()); + endpoint.close(); + } else { + log.trace("connection to client is already closed"); + } + } + + /** + * Invoked when a device connects, after authentication. + *

+ * This method is only visible for testing purposes. + * + * @param vertx The vert.x instance + * @return The command handler for the given device. + */ + CommandSubscriptionsManager createCommandHandler(final Vertx vertx) { + return new CommandSubscriptionsManager(vertx, mqttGatewayConfig); + } + + /** + * Invoked when a device publishes a message. + * + * Invokes {@link #onPublishedMessage(MqttDownstreamContext)}, uploads the message to Hono's AMQP adapter. + * Afterwards it invokes {@link #onMessageSent(MqttDownstreamContext)} if the message has been forwarded + * successfully or if a the message could not be delivered, {@link #onMessageUndeliverable(MqttDownstreamContext)}. + * + * @param ctx The context in which the MQTT message has been published. + * @throws NullPointerException if the context is {@code null}. + */ + private void handlePublishedMessage(final MqttDownstreamContext ctx) { + + Objects.requireNonNull(ctx); + + onPublishedMessage(ctx) + .compose(downstreamMessage -> uploadMessage(downstreamMessage, ctx)) + .onComplete(processing -> { + if (processing.succeeded()) { + onUploadSuccess(ctx); + onMessageSent(ctx); + } else { + onUploadFailure(ctx, processing.cause()); + } + }); + } + + private Future uploadMessage(final DownstreamMessage downstreamMessage, + final MqttDownstreamContext ctx) { + + final String tenantId = ctx.authenticatedDevice().getTenantId(); + final String deviceId = ctx.authenticatedDevice().getDeviceId(); + final Map properties = downstreamMessage.getApplicationProperties(); + final byte[] payload = downstreamMessage.getPayload(); + final String contentType = downstreamMessage.getContentType(); + + if (downstreamMessage instanceof TelemetryMessage) { + + final TelemetryMessage telemetryMessage = (TelemetryMessage) downstreamMessage; + return sendTelemetry(tenantId, deviceId, properties, payload, contentType, + telemetryMessage.shouldWaitForOutcome()); + + } else if (downstreamMessage instanceof EventMessage) { + + return sendEvent(tenantId, deviceId, properties, payload, contentType); + + } else if (downstreamMessage instanceof CommandResponseMessage) { + + final CommandResponseMessage response = (CommandResponseMessage) downstreamMessage; + return sendCommandResponse(tenantId, deviceId, response.getTargetAddress(tenantId, deviceId), + response.getCorrelationId(), response.getStatus(), payload, contentType, properties); + + } else { + return Future.failedFuture(new ClientErrorException(HttpURLConnection.HTTP_BAD_REQUEST, + String.format("uploading message failed [topic: %s]. Unknown message type [%s]", ctx.topic(), + downstreamMessage.getClass().getSimpleName()))); + } + } + + private void onUploadSuccess(final MqttDownstreamContext ctx) { + log.debug("successfully processed message [topic: {}, QoS: {}] from device [tenantId: {}, deviceId: {}]", + ctx.topic(), ctx.qosLevel(), ctx.authenticatedDevice().getTenantId(), + ctx.authenticatedDevice().getDeviceId()); + // check that the remote MQTT client is still connected before sending PUBACK + if (MqttQoS.AT_LEAST_ONCE.equals(ctx.qosLevel()) && ctx.deviceEndpoint().isConnected()) { + log.debug("sending PUBACK"); + ctx.acknowledge(); + } + } + + private void onUploadFailure(final MqttDownstreamContext ctx, final Throwable cause) { + + final int statusCode = ServiceInvocationException.extractStatusCode(cause); + + if (statusCode < 500) { + log.debug("Publish message [topic: {}] from {} failed with client error: ", ctx.topic(), + ctx.authenticatedDevice(), cause); + } else { + log.info("Publish message [topic: {}] from {} failed: ", ctx.topic(), ctx.authenticatedDevice(), cause); + onMessageUndeliverable(ctx); + } + + if (ctx.deviceEndpoint().isConnected()) { + log.info("closing connection to device {}", ctx.authenticatedDevice().toString()); + ctx.deviceEndpoint().close(); + } + } + + private Future sendTelemetry(final String tenantId, final String deviceId, + final Map properties, final byte[] payload, final String contentType, + final boolean waitForOutcome) { + + return clientFactoryPerTenant.get(tenantId).getOrCreateTelemetrySender() + .compose(sender -> { + if (waitForOutcome) { + log.trace( + "sending telemetry message and wait for outcome [tenantId: {}, deviceId: {}, contentType: {}, properties: {}]", + tenantId, deviceId, contentType, properties); + return sender.sendAndWaitForOutcome(deviceId, payload, contentType, properties); + } else { + log.trace( + "sending telemetry message [tenantId: {}, deviceId: {}, contentType: {}, properties: {}]", + tenantId, deviceId, contentType, properties); + return sender.send(deviceId, payload, contentType, properties); + } + }); + } + + private Future sendEvent(final String tenantId, final String deviceId, + final Map properties, final byte[] payload, final String contentType) { + + log.trace("sending event message [tenantId: {}, deviceId: {}, contentType: {}, properties: {}]", + tenantId, deviceId, contentType, properties); + + return clientFactoryPerTenant.get(tenantId).getOrCreateEventSender() + .compose(sender -> sender.send(deviceId, payload, contentType, properties)); + } + + private Future sendCommandResponse(final String tenantId, final String deviceId, + final String targetAddress, final String correlationId, final int status, final byte[] payload, + final String contentType, final Map properties) { + + log.trace( + "sending command response [tenantId: {}, deviceId: {}, targetAddress: {}, correlationId: {}, status: {}, contentType: {}, properties: {}]", + tenantId, deviceId, targetAddress, correlationId, status, contentType, properties); + + return clientFactoryPerTenant.get(tenantId).getOrCreateCommandResponseSender() + .compose(sender -> sender.sendCommandResponse(deviceId, targetAddress, correlationId, status, payload, + contentType, properties)); + } + + /** + * Invoked when a device sends an MQTT SUBSCRIBE packet. + * + * It invokes {@link #isTopicFilterValid(String, String, String, String)} for each topic filter in the subscribe + * packet. If there is a valid topic filter and no command consumer already exists for this device, this method + * opens a device-specific command consumer for receiving commands from applications for the device. + * + * @param endpoint The endpoint representing the connection to the device. + * @param authenticatedDevice The authenticated identity of the device. + * @param subscribeMsg The subscribe request received from the device. + * @param cmdSubscriptionsManager The CommandSubscriptionsManager to track command subscriptions, unsubscriptions + * and handle PUBACKs. + * @throws NullPointerException if any of the parameters is {@code null}. + */ + private void onSubscribe(final MqttEndpoint endpoint, final Device authenticatedDevice, + final MqttSubscribeMessage subscribeMsg, final CommandSubscriptionsManager cmdSubscriptionsManager) { + + Objects.requireNonNull(endpoint); + Objects.requireNonNull(authenticatedDevice); + Objects.requireNonNull(subscribeMsg); + Objects.requireNonNull(cmdSubscriptionsManager); + + @SuppressWarnings("rawtypes") + final List subscriptionOutcome = new ArrayList<>(subscribeMsg.topicSubscriptions().size()); + + subscribeMsg.topicSubscriptions().forEach(subscription -> { + + final Future result; + + if (isTopicFilterValid(subscription.topicName(), authenticatedDevice.getTenantId(), + authenticatedDevice.getDeviceId(), endpoint.clientIdentifier())) { + + // we do not support subscribing to commands using QoS 2 + final MqttQoS grantedQos = MqttQoS.EXACTLY_ONCE.equals(subscription.qualityOfService()) + ? MqttQoS.AT_LEAST_ONCE + : subscription.qualityOfService(); + + final CommandSubscription cmdSub = new CommandSubscription(subscription.topicName(), grantedQos, + endpoint.clientIdentifier()); + + result = cmdSubscriptionsManager.addSubscription(cmdSub, + () -> createCommandConsumer(endpoint, cmdSubscriptionsManager, authenticatedDevice)); + } else { + log.debug("cannot create subscription [filter: {}, requested QoS: {}]: unsupported topic filter", + subscription.topicName(), subscription.qualityOfService()); + result = Future.succeededFuture(MqttQoS.FAILURE); + } + subscriptionOutcome.add(result); + }); + + // wait for all futures to complete before sending SUBACK + CompositeFuture.join(subscriptionOutcome).onComplete(v -> { + + // return a status code for each topic filter contained in the SUBSCRIBE packet + final List grantedQosLevels = subscriptionOutcome.stream() + .map(Future::result) + .map(result -> (MqttQoS) result) + .collect(Collectors.toList()); + + if (endpoint.isConnected()) { + endpoint.subscribeAcknowledge(subscribeMsg.messageId(), grantedQosLevels); + } + }); + } + + /** + * Invoked when a device sends an MQTT UNSUBSCRIBE packet. + * + * @param endpoint The endpoint representing the connection to the device. + * @param authenticatedDevice The authenticated identity of the device. + * @param unsubscribeMsg The unsubscribe request received from the device. + * @param cmdSubscriptionsManager The CommandSubscriptionsManager to track command subscriptions, unsubscriptions + * and handle PUBACKs. + * @throws NullPointerException if any of the parameters is {@code null}. + */ + private void onUnsubscribe(final MqttEndpoint endpoint, final Device authenticatedDevice, + final MqttUnsubscribeMessage unsubscribeMsg, final CommandSubscriptionsManager cmdSubscriptionsManager) { + + Objects.requireNonNull(endpoint); + Objects.requireNonNull(authenticatedDevice); + Objects.requireNonNull(unsubscribeMsg); + Objects.requireNonNull(cmdSubscriptionsManager); + + unsubscribeMsg.topics().forEach(topic -> { + if (!isTopicFilterValid(topic, authenticatedDevice.getTenantId(), authenticatedDevice.getDeviceId(), + endpoint.clientIdentifier())) { + log.debug("ignoring unsubscribe request for unsupported topic filter [{}]", topic); + } else { + log.debug("unsubscribing device [tenant-id: {}, device-id: {}] from topic [{}]", + authenticatedDevice.getTenantId(), authenticatedDevice.getDeviceId(), topic); + cmdSubscriptionsManager.removeSubscription(topic); + } + }); + if (endpoint.isConnected()) { + endpoint.unsubscribeAcknowledge(unsubscribeMsg.messageId()); + } + } + + private Future createCommandConsumer(final MqttEndpoint endpoint, + final CommandSubscriptionsManager cmdSubscriptionsManager, final Device authenticatedDevice) { + return clientFactoryPerTenant.get(authenticatedDevice.getTenantId()).createDeviceSpecificCommandConsumer( + authenticatedDevice.getDeviceId(), + cmd -> handleCommand(endpoint, cmd, cmdSubscriptionsManager, authenticatedDevice)); + } + + private void handleCommand(final MqttEndpoint endpoint, final Message message, + final CommandSubscriptionsManager cmdSubscriptionsManager, final Device authenticatedDevice) { + + if (message.getReplyTo() != null) { + log.debug("Received request/response command [subject: {}, correlationID: {}, messageID: {}, reply-to: {}]", + message.getSubject(), message.getCorrelationId(), message.getMessageId(), message.getReplyTo()); + } else { + log.debug("Received one-way command [subject: {}]", message.getSubject()); + } + + final MqttCommandContext ctx = MqttCommandContext.fromAmqpMessage(message, authenticatedDevice); + final Command command = onCommandReceived(ctx); + + if (command == null) { + throw new IllegalStateException("onCommandReceived returned null"); + } + + final CommandSubscription subscription = cmdSubscriptionsManager.getSubscriptions() + .get(command.getTopicFilter()); + if (subscription == null) { + throw new IllegalStateException( + String.format("No subscription found for topic filter %s. Discarding message from %s", + command.getTopicFilter(), authenticatedDevice.toString())); + } + + log.debug("Publishing command on topic [{}] to device {} [MQTT client-id: {}, QoS: {}]", command.getTopic(), + authenticatedDevice.toString(), endpoint.clientIdentifier(), subscription.getQos()); + + endpoint.publish(command.getTopic(), command.getPayload(), subscription.getQos(), false, false, + ar -> afterCommandPublished(ar.result(), message, authenticatedDevice, subscription, + cmdSubscriptionsManager)); + + } + + // Vert.x only calls this handler after it successfully published the message, otherwise it throws an exception + // which causes the AMQP Command Consumer not to be settled (and the backend application to receive an error) + private void afterCommandPublished(final Integer publishedMsgId, final Message message, + final Device authenticatedDevice, final CommandSubscription subscription, + final CommandSubscriptionsManager cmdSubscriptionsManager) { + + if (MqttQoS.AT_LEAST_ONCE.equals(subscription.getQos())) { + + final Handler onAckHandler = msgId -> { + + onCommandPublished(message, subscription); + + log.debug( + "Acknowledged [Msg-id: {}] command to device [tenant-id: {}, device-id: {}, MQTT client-id: {}, QoS: {}]", + msgId, authenticatedDevice.getTenantId(), authenticatedDevice.getDeviceId(), + subscription.getClientId(), subscription.getQos()); + }; + + final Handler onAckTimeoutHandler = v -> log.debug( + "Timed out waiting for acknowledgment for command sent to device [tenant-id: {}, device-id: {}, MQTT client-id: {}, QoS: {}]", + authenticatedDevice.getTenantId(), authenticatedDevice.getDeviceId(), + subscription.getClientId(), subscription.getQos()); + + cmdSubscriptionsManager.addToWaitingForAcknowledgement(publishedMsgId, onAckHandler, onAckTimeoutHandler); + } else { + onCommandPublished(message, subscription); + } + } + + /** + * {@inheritDoc} + *

+ * Creates and starts the MQTT server and invokes {@link #afterStartup(Promise)} afterwards. + */ + @Override + public final void start(final Promise startPromise) { + + if (mqttGatewayConfig.getKeyCertOptions() == null + && mqttGatewayConfig.getPort() == MqttServerOptions.DEFAULT_TLS_PORT) { + log.error("configuration must have key & certificate if port 8883 is configured"); + startPromise.fail("TLS configuration invalid"); + } + + MqttServer.create(vertx, getMqttServerOptions()) + .endpointHandler(this::handleEndpointConnection) + .listen(asyncResult -> { + if (asyncResult.succeeded()) { + final MqttServer startedServer = asyncResult.result(); + log.info("MQTT server running on {}:{}", mqttGatewayConfig.getBindAddress(), + startedServer.actualPort()); + server = startedServer; + afterStartup(startPromise); + } else { + log.error("error while starting up MQTT server", asyncResult.cause()); + startPromise.fail(asyncResult.cause()); + } + }); + } + + /** + * Returns the options for the MQTT server. + *

+ * This method is only visible for testing purposes. + * + * @return The options configured with the values of the {@link MqttProtocolGatewayConfig}. + */ + MqttServerOptions getMqttServerOptions() { + final MqttServerOptions options = new MqttServerOptions() + .setHost(mqttGatewayConfig.getBindAddress()) + .setPort(mqttGatewayConfig.getPort()); + + addTlsKeyCertOptions(options); + addTlsTrustOptions(options); + return options; + } + + private void addTlsKeyCertOptions(final NetServerOptions serverOptions) { + + final KeyCertOptions keyCertOptions = mqttGatewayConfig.getKeyCertOptions(); + + if (keyCertOptions != null) { + serverOptions.setSsl(true).setKeyCertOptions(keyCertOptions); + log.info("Enabling TLS"); + + final LinkedHashSet enabledProtocols = new LinkedHashSet<>(mqttGatewayConfig.getSecureProtocols()); + serverOptions.setEnabledSecureTransportProtocols(enabledProtocols); + log.info("Enabling secure protocols [{}]", enabledProtocols); + + serverOptions.setSni(mqttGatewayConfig.isSni()); + log.info("Supporting TLS ServerNameIndication: {}", mqttGatewayConfig.isSni()); + } + } + + private void addTlsTrustOptions(final NetServerOptions serverOptions) { + + if (serverOptions.isSsl()) { + + final TrustOptions trustOptions = mqttGatewayConfig.getTrustOptions(); + if (trustOptions != null) { + serverOptions.setTrustOptions(trustOptions).setClientAuth(ClientAuth.REQUEST); + log.info("Enabling client authentication using certificates [{}]", trustOptions.getClass().getName()); + } + } + } + + /** + * {@inheritDoc} + *

+ * Invokes {@link #beforeShutdown(Promise)} and stops the MQTT server. + */ + @Override + public final void stop(final Promise stopPromise) { + + final Promise stopTracker = Promise.promise(); + beforeShutdown(stopTracker); + stopTracker.future().onComplete(v -> { + if (server != null) { + server.close(stopPromise); + } else { + stopPromise.complete(); + } + }); + + } + + /** + * Invoked directly before the gateway is shut down. + *

+ * This default implementation always completes the promise. + *

+ * Subclasses should override this method to perform any work required before shutting down this protocol gateway. + * + * @param stopPromise The promise to complete once all work is done and shut down should commence. + */ + protected void beforeShutdown(final Promise stopPromise) { + stopPromise.complete(); + } + + /** + * Invoked after the gateway has started up. + *

+ * This default implementation simply completes the promise. + *

+ * Subclasses should override this method to perform any work required on start-up of this protocol gateway. + * + * @param startPromise The promise to complete once start up is complete. + */ + protected void afterStartup(final Promise startPromise) { + startPromise.complete(); + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Command.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Command.java new file mode 100644 index 00000000..998a6ae5 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Command.java @@ -0,0 +1,77 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.util.Objects; + +import io.vertx.core.buffer.Buffer; + +/** + * The command message that will be published via MQTT to a device. + *

+ * Devices can use wildcards in the topic filter, so there is no easy way to uniquely identify the corresponding + * subscription and thus the required Qos. To ensure this, the topic filter must match exactly the one the device has + * subscribed to. + */ +public class Command { + + private final String topic; + private final String topicFilter; + private final Buffer payload; + + /** + * Creates an instance. + * + * @param topic The topic on which the command should be sent to the device. + * @param topicFilter The topic filter to which the device has subscribed. + * @param payload The payload of the command. + */ + public Command(final String topic, final String topicFilter, final Buffer payload) { + Objects.requireNonNull(topic); + Objects.requireNonNull(topicFilter); + Objects.requireNonNull(payload); + + this.topic = topic; + this.topicFilter = topicFilter; + this.payload = payload; + } + + /** + * Gets the topic on which the command should be sent to the device. + * + * @return The topic. + */ + public String getTopic() { + return topic; + } + + /** + * Returns the topic filter to which the device has subscribed. + * + * @return The topic filter. + */ + public String getTopicFilter() { + return topicFilter; + } + + /** + * Returns the payload to be published to the device. + * + * @return The payload. + */ + public Buffer getPayload() { + return payload; + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscription.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscription.java new file mode 100644 index 00000000..0f74b56b --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscription.java @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2018, 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.util.Objects; + +import io.netty.handler.codec.mqtt.MqttQoS; + +/** + * The MQTT subscription of devices, to get commands. + * + */ +public class CommandSubscription { + + private final String topicFilter; + private final MqttQoS qos; + private final String clientId; + + /** + * Creates a command subscription object for the given topic filter. + * + * @param topicFilter The topic filter in the subscription request from device. + * @param qos The MQTT QoS of the subscription. + * @param clientId The client identifier as provided by the remote MQTT client. + * @throws NullPointerException if one of the arguments is {@code null}. + * @throws IllegalArgumentException if the topic filter does not match the rules or any of the arguments is not + * valid. + **/ + public CommandSubscription(final String topicFilter, final MqttQoS qos, final String clientId) { + Objects.requireNonNull(topicFilter); + Objects.requireNonNull(qos); + Objects.requireNonNull(clientId); + + this.topicFilter = topicFilter; + this.qos = qos; + this.clientId = clientId; + } + + /** + * Gets the QoS of the subscription. + * + * @return The QoS value. + */ + public MqttQoS getQos() { + return qos; + } + + /** + * Gets the clientId of the Mqtt subscription. + * + * @return The clientId. + */ + public String getClientId() { + return clientId; + } + + /** + * Gets the subscription topic filter. + * + * @return The topic filter. + */ + public String getTopicFilter() { + return topicFilter; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final CommandSubscription that = (CommandSubscription) o; + return topicFilter.equals(that.topicFilter) && + qos == that.qos && + clientId.equals(that.clientId); + } + + @Override + public int hashCode() { + return Objects.hash(topicFilter, qos, clientId); + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscriptionsManager.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscriptionsManager.java new file mode 100644 index 00000000..255a8b04 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/CommandSubscriptionsManager.java @@ -0,0 +1,230 @@ +/******************************************************************************* + * Copyright (c) 2016, 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.eclipse.hono.client.MessageConsumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; + +/** + * A class that tracks command subscriptions, unsubscriptions and handles PUBACKs. + */ +final class CommandSubscriptionsManager { + + private static final Logger LOG = LoggerFactory.getLogger(CommandSubscriptionsManager.class); + /** + * Map of the current subscriptions. Key is the topic name. + */ + private final Map subscriptions = new ConcurrentHashMap<>(); + /** + * Map of the requests waiting for an acknowledgement. Key is the command message id. + */ + private final Map waitingForAcknowledgement = new ConcurrentHashMap<>(); + private final Vertx vertx; + private final MqttProtocolGatewayConfig config; + private Future commandConsumer; + + /** + * Creates a new CommandSubscriptionsManager instance. + * + * @param vertx The Vert.x instance to execute the client on. + * @param config The configuration properties to use. + * @throws NullPointerException if any of the parameters are {@code null}. + */ + CommandSubscriptionsManager(final Vertx vertx, final MqttProtocolGatewayConfig config) { + this.vertx = Objects.requireNonNull(vertx); + this.config = Objects.requireNonNull(config); + } + + /** + * Invoked when a device sends an MQTT PUBACK packet. + * + * @param msgId The msgId of the command published with QoS 1. + * @throws NullPointerException if msgId is {@code null}. + */ + public void handlePubAck(final Integer msgId) { + Objects.requireNonNull(msgId); + LOG.trace("Acknowledgement received for command [Msg-id: {}] that has been sent to device.", msgId); + Optional.ofNullable(removeFromWaitingForAcknowledgement(msgId)).ifPresent(value -> { + cancelTimer(value.timerId); + value.onAckHandler.handle(msgId); + }); + } + + /** + * Registers handlers to be invoked when the command message with the given id is either acknowledged or a timeout + * occurs. + * + * @param msgId The id of the command (message) that has been published. + * @param onAckHandler Handler to invoke when the device has acknowledged the command. + * @param onAckTimeoutHandler Handler to invoke when there is a timeout waiting for the acknowledgement from the + * device. + * @throws NullPointerException if any of the parameters is {@code null}. + */ + public void addToWaitingForAcknowledgement(final Integer msgId, final Handler onAckHandler, + final Handler onAckTimeoutHandler) { + + Objects.requireNonNull(msgId); + Objects.requireNonNull(onAckHandler); + Objects.requireNonNull(onAckTimeoutHandler); + + waitingForAcknowledgement.put(msgId, + new PendingCommandRequest(startTimer(msgId), onAckHandler, onAckTimeoutHandler)); + } + + /** + * Removes the entry from the waitingForAcknowledgement map for the given msgId. + * + * @param msgId The id of the command (message) that has been published. + * @return The PendingCommandRequest object containing timer-id and event handlers. + */ + private PendingCommandRequest removeFromWaitingForAcknowledgement(final Integer msgId) { + return waitingForAcknowledgement.remove(msgId); + } + + /** + * Stores the command subscription and creates a command consumer for it. Multiple subscription share the same + * consumer. + * + * @param subscription The device's command subscription. + * @param commandConsumerSupplier A function to create a client for consuming messages. + * @throws NullPointerException if any of the parameters are {@code null}. + * @return The QoS of the subscription or {@link MqttQoS#FAILURE} if the consumer could not be opened. + */ + public Future addSubscription(final CommandSubscription subscription, + final Supplier> commandConsumerSupplier) { + + Objects.requireNonNull(subscription); + Objects.requireNonNull(commandConsumerSupplier); + + LOG.trace("Adding subscription for topic filter [{}]", subscription.getTopicFilter()); + + if (commandConsumer == null) { + commandConsumer = commandConsumerSupplier.get(); + } + + return commandConsumer + .map(messageConsumer -> { + subscriptions.put(subscription.getTopicFilter(), subscription); + LOG.debug("Added subscription for topic filter [{}] with QoS {}", subscription.getTopicFilter(), + subscription.getQos()); + return subscription.getQos(); + }) + .otherwise(MqttQoS.FAILURE); + } + + /** + * Removes the subscription entry for the given topic filter. If this was the only subscription, the command + * consumer will be closed. + * + * @param topicFilter The topic filter string to unsubscribe. + * @throws NullPointerException if the topic filter is {@code null}. + */ + public void removeSubscription(final String topicFilter) { + Objects.requireNonNull(topicFilter); + + final CommandSubscription value = subscriptions.remove(topicFilter); + if (value != null) { + LOG.debug("Remove subscription for topic filter [{}]", topicFilter); + + if (subscriptions.isEmpty() && commandConsumer != null && commandConsumer.succeeded()) { + closeCommandConsumer(commandConsumer.result()); + } + } else { + LOG.debug("Cannot remove subscription; none registered for topic filter [{}].", topicFilter); + } + } + + /** + * Removes all the subscription entries and closes the command consumer. + * + **/ + public void removeAllSubscriptions() { + subscriptions.keySet().forEach(this::removeSubscription); + } + + private void closeCommandConsumer(final MessageConsumer consumer) { + consumer.close(cls -> { + if (cls.succeeded()) { + LOG.debug("Command consumer closed"); + commandConsumer = null; + } else { + LOG.error("Error closing command consumer", cls.cause()); + } + }); + } + + private long startTimer(final Integer msgId) { + + return vertx.setTimer(config.getCommandAckTimeout(), timerId -> { + Optional.ofNullable(removeFromWaitingForAcknowledgement(msgId)) + .ifPresent(value -> value.onAckTimeoutHandler.handle(null)); + }); + } + + private void cancelTimer(final Long timerId) { + vertx.cancelTimer(timerId); + LOG.trace("Canceled Timer [timer-id: {}}", timerId); + } + + /** + * Returns all subscriptions of this device. + * + * @return An unmodifiable view of the subscriptions map with the topic filter as key and the subscription object as + * value. + */ + public Map getSubscriptions() { + return Collections.unmodifiableMap(subscriptions); + } + + /** + * A class to facilitate storing of information in connection with the pending command requests. The pending command + * requests are tracked using a map in the enclosing class {@link CommandSubscriptionsManager}. + */ + private static class PendingCommandRequest { + + private final Long timerId; + private final Handler onAckHandler; + private final Handler onAckTimeoutHandler; + + /** + * Creates a new PendingCommandRequest instance. + * + * @param timerId The unique ID of the timer. + * @param onAckHandler Handler to invoke when the device has acknowledged the command. + * @param onAckTimeoutHandler Handler to invoke when there is a timeout waiting for the acknowledgement from the + * device. + * @throws NullPointerException if any of the parameters is {@code null}. + */ + private PendingCommandRequest(final Long timerId, final Handler onAckHandler, + final Handler onAckTimeoutHandler) { + this.timerId = Objects.requireNonNull(timerId); + this.onAckHandler = Objects.requireNonNull(onAckHandler); + this.onAckTimeoutHandler = Objects.requireNonNull(onAckTimeoutHandler); + } + + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Credentials.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Credentials.java new file mode 100644 index 00000000..861de081 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/Credentials.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.util.Objects; + +/** + * Simple data structure that wraps a username an a password. + */ +public class Credentials { + + private final String username; + private final String password; + + /** + * Creates an instance. + * + * @param username The user name. + * @param password The password. + * @throws NullPointerException if any of the parameters is {@code null}. + */ + public Credentials(final String username, final String password) { + Objects.requireNonNull(username); + Objects.requireNonNull(password); + + this.username = username; + this.password = password; + } + + /** + * Returns the username. + * + * @return The username - not {@code null}. + */ + public String getUsername() { + return username; + } + + /** + * Returns the password. + * + * @return The password - not {@code null}. + */ + public String getPassword() { + return password; + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttCommandContext.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttCommandContext.java new file mode 100644 index 00000000..b42a71d6 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttCommandContext.java @@ -0,0 +1,138 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.util.Objects; + +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.message.Message; +import org.eclipse.hono.auth.Device; +import org.eclipse.hono.util.MessageHelper; + +import io.vertx.core.buffer.Buffer; + +/** + * A dictionary of data for the processing of a command message received from Hono's AMQP adapter. + */ +public class MqttCommandContext { + + private final Message message; + private final Device authenticatedDevice; + + private MqttCommandContext(final Message message, final Device authenticatedDevice) { + this.message = message; + this.authenticatedDevice = authenticatedDevice; + } + + /** + * Creates a new context for a command message. + * + * @param message The received command message. + * @param authenticatedDevice The authenticated device identity. + * @return The context. + * @throws NullPointerException if any of the parameters is {@code null}. + */ + public static MqttCommandContext fromAmqpMessage(final Message message, final Device authenticatedDevice) { + Objects.requireNonNull(message); + Objects.requireNonNull(authenticatedDevice); + + return new MqttCommandContext(message, authenticatedDevice); + } + + /** + * Gets the identity of the device to which the command is addressed to. + * + * @return The authenticated device. + */ + public Device getDevice() { + return authenticatedDevice; + } + + /** + * Indicates if the message represents a request/response or an one-way command. + * + * @return {@code true} if a response is expected. + */ + public boolean isRequestResponseCommand() { + return message.getReplyTo() != null; + } + + /** + * Returns the subject of the command message. + * + * @return The subject. + */ + public String getSubject() { + return message.getSubject(); + } + + /** + * Returns the content type of the payload if this information is available from the message. + * + * @return The content type or {@code null}. + */ + public String getContentType() { + return message.getContentType(); + } + + /** + * Returns the correlation id of the message. + * + * @return The correlation id. + */ + public Object getCorrelationId() { + return message.getCorrelationId(); + } + + /** + * Returns the message id of the message. + * + * @return The message id. + */ + public Object getMessageId() { + return message.getMessageId(); + } + + /** + * Returns the reply-to address of the message. + * + * @return The reply-to address. + */ + public String getReplyTo() { + return message.getReplyTo(); + } + + /** + * Returns the application properties of the message. + * + * @return The application properties. + */ + public ApplicationProperties getApplicationProperties() { + return message.getApplicationProperties(); + } + + /** + * Returns the received payload. + * + * @return The payload - not {@code null}. + */ + public Buffer getPayload() { + final Buffer payload = MessageHelper.getPayload(message); + if (payload != null) { + return payload; + } else { + return Buffer.buffer(); + } + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttDownstreamContext.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttDownstreamContext.java new file mode 100644 index 00000000..d2dfd9ce --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttDownstreamContext.java @@ -0,0 +1,119 @@ +/******************************************************************************* + * Copyright (c) 2016, 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.util.Objects; + +import org.eclipse.hono.auth.Device; + +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.mqtt.MqttEndpoint; +import io.vertx.mqtt.messages.MqttPublishMessage; + +/** + * A dictionary of relevant information required during the processing of an MQTT message published by a device. + * + */ +public class MqttDownstreamContext { + + private final MqttPublishMessage message; + private final MqttEndpoint deviceEndpoint; + private final Device authenticatedDevice; + private final String topic; + private final MqttQoS qos; + + private MqttDownstreamContext(final Device authenticatedDevice, final MqttPublishMessage publishedMessage, + final MqttEndpoint deviceEndpoint, final String topic) { + this.authenticatedDevice = authenticatedDevice; + this.message = publishedMessage; + this.deviceEndpoint = deviceEndpoint; + this.topic = topic; + this.qos = publishedMessage.qosLevel(); + } + + /** + * Creates a new context for a published message. + * + * @param message The published MQTT message. + * @param deviceEndpoint The endpoint representing the device that has published the message. + * @param authenticatedDevice The authenticated device identity. + * @return The context. + * @throws NullPointerException if any of the parameters are {@code null}. + */ + public static MqttDownstreamContext fromPublishPacket( + final MqttPublishMessage message, + final MqttEndpoint deviceEndpoint, + final Device authenticatedDevice) { + + Objects.requireNonNull(message); + Objects.requireNonNull(deviceEndpoint); + Objects.requireNonNull(authenticatedDevice); + + return new MqttDownstreamContext(authenticatedDevice, message, deviceEndpoint, message.topicName()); + } + + /** + * Gets the MQTT message to process. + * + * @return The message. + */ + public MqttPublishMessage message() { + return message; + } + + /** + * Gets the MQTT endpoint over which the message has been received. + * + * @return The endpoint. + */ + MqttEndpoint deviceEndpoint() { + return deviceEndpoint; + } + + /** + * Gets the identity of the authenticated device that has published the message. + * + * @return The identity or {@code null} if the device has not been authenticated. + */ + public Device authenticatedDevice() { + return authenticatedDevice; + } + + /** + * Gets the topic that the message has been published to. + * + * @return The topic. + */ + public String topic() { + return topic; + } + + /** + * Gets the QoS level of the published MQTT message. + * + * @return The QoS. + */ + public MqttQoS qosLevel() { + return qos; + } + + /** + * Sends a PUBACK for the message to the device. + */ + public void acknowledge() { + if (message != null && deviceEndpoint != null) { + deviceEndpoint.publishAcknowledge(message.messageId()); + } + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttProtocolGatewayConfig.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttProtocolGatewayConfig.java new file mode 100644 index 00000000..159fcc92 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/MqttProtocolGatewayConfig.java @@ -0,0 +1,134 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.util.Objects; + +import org.eclipse.hono.config.AbstractConfig; +import org.eclipse.hono.util.Constants; + +/** + * Configuration of server properties for {@link AbstractMqttProtocolGateway}. + */ +public class MqttProtocolGatewayConfig extends AbstractConfig { + + /** + * The default number of milliseconds to wait for PUBACK. + */ + protected static final int DEFAULT_COMMAND_ACK_TIMEOUT = 100; + + private int commandAckTimeout = DEFAULT_COMMAND_ACK_TIMEOUT; + private int port = 0; + private String bindAddress = Constants.LOOPBACK_DEVICE_ADDRESS; + private boolean sni; + + /** + * Gets the host name or literal IP address of the network interface that this server's secure port is configured to + * be bound to. + * + * @return The host name. + */ + public final String getBindAddress() { + return bindAddress; + } + + /** + * Sets the host name or literal IP address of the network interface that this server's secure port should be bound + * to. + *

+ * The default value of this property is {@link Constants#LOOPBACK_DEVICE_ADDRESS} on IPv4 stacks. + * + * @param address The host name or IP address. + * @throws NullPointerException if host is {@code null}. + */ + public final void setBindAddress(final String address) { + this.bindAddress = Objects.requireNonNull(address); + } + + /** + * Gets the port this server is configured to listen on. + * + * @return The port number. + */ + public final int getPort() { + return port; + } + + /** + * Sets the port that this server should listen on. + *

+ * If the port is set to 0 (the default value), then this server will bind to an arbitrary free port chosen by the + * operating system during startup. + * + * @param port The port number. + * @throws IllegalArgumentException if port < 0 or port > 65535. + */ + public final void setPort(final int port) { + if (isValidPort(port)) { + this.port = port; + } else { + throw new IllegalArgumentException("invalid port number"); + } + } + + /** + * Sets whether the server should support Server Name Indication for TLS connections. + * + * @param sni {@code true} if the server should support SNI. + */ + public final void setSni(final boolean sni) { + this.sni = sni; + } + + /** + * Checks if the server supports Server Name Indication for TLS connections. + * + * @return {@code true} if the server supports SNI. + */ + public final boolean isSni() { + return this.sni; + } + + /** + * Gets the waiting for acknowledgement time out in milliseconds for commands published with QoS 1. + *

+ * This time out is used by the MQTT protocol gateway for commands published with QoS 1. If there is no + * acknowledgement within this time limit, then the command is settled with the released outcome. + *

+ * The default value is {@link #DEFAULT_COMMAND_ACK_TIMEOUT}. + * + * @return The time out in milliseconds. + */ + public final int getCommandAckTimeout() { + return commandAckTimeout; + } + + /** + * Sets the waiting for acknowledgement time out in milliseconds for commands published with QoS 1. + *

+ * This time out is used by the MQTT protocol gateway for commands published with QoS 1. If there is no + * acknowledgement within this time limit, then the command is settled with the released outcome. + *

+ * The default value is {@link #DEFAULT_COMMAND_ACK_TIMEOUT}. + * + * @param timeout The time out in milliseconds. + * @throws IllegalArgumentException if the timeout is negative. + */ + public final void setCommandAckTimeout(final int timeout) { + if (timeout < 0) { + throw new IllegalArgumentException("timeout must not be negative"); + } + this.commandAckTimeout = timeout; + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/X509CertificateValidator.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/X509CertificateValidator.java new file mode 100644 index 00000000..c4d87e55 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/X509CertificateValidator.java @@ -0,0 +1,86 @@ +/******************************************************************************* + * Copyright (c) 2016, 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.security.GeneralSecurityException; +import java.security.cert.CertPath; +import java.security.cert.CertPathValidator; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.vertx.core.Future; +import io.vertx.core.Promise; + +/** + * Validates a device's certificate chain using a {@link CertPathValidator}. + * + */ +public class X509CertificateValidator { + + private static final Logger LOG = LoggerFactory.getLogger(X509CertificateValidator.class); + + /** + * Validates a certificate path based on a list of trust anchors. + * + * @param chain The certificate chain to validate. The end certificate must be at position 0. + * @param trustAnchors The list of trust anchors to use for validating the chain. + * @return A completed future if the path is valid (according to the implemented tests). Otherwise, the future will + * be failed with a {@link CertificateException}. + * @throws NullPointerException if any of the parameters are {@code null}. + * @throws IllegalArgumentException if the chain or trust anchor list are empty. + */ + public Future validate(final List chain, final Set trustAnchors) { + + Objects.requireNonNull(chain); + Objects.requireNonNull(trustAnchors); + + if (chain.isEmpty()) { + throw new IllegalArgumentException("certificate chain must not be empty"); + } else if (trustAnchors.isEmpty()) { + throw new IllegalArgumentException("trust anchor list must not be empty"); + } + + final Promise result = Promise.promise(); + + try { + final PKIXParameters params = new PKIXParameters(trustAnchors); + params.setRevocationEnabled(false); + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + final CertPath path = factory.generateCertPath(chain); + final CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + validator.validate(path, params); + LOG.debug("validation of device certificate [subject DN: {}] succeeded", + chain.get(0).getSubjectX500Principal().getName()); + result.complete(); + } catch (GeneralSecurityException e) { + LOG.debug("validation of device certificate [subject DN: {}] failed", + chain.get(0).getSubjectX500Principal().getName(), e); + if (e instanceof CertificateException) { + result.fail(e); + } else { + result.fail(new CertificateException("validation of device certificate failed", e)); + } + } + return result.future(); + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/CommandResponseMessage.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/CommandResponseMessage.java new file mode 100644 index 00000000..9378e8d8 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/CommandResponseMessage.java @@ -0,0 +1,110 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream; + +import java.util.Objects; + +import org.eclipse.hono.util.CommandConstants; + +import io.vertx.core.buffer.Buffer; + +/** + * This class holds required data of a command response. + */ +public final class CommandResponseMessage extends DownstreamMessage { + + private final String replyId; + private final String correlationId; + private final int status; + + /** + * Creates an instance. + * + * @param replyId The reply id of the command. + * @param correlationId The correlation id (taken from the command). + * @param status The outcome of the command as a HTTP status code. + * @param payload The payload to be used. + * @throws NullPointerException if any of the parameters is {@code null}. + * @throws IllegalArgumentException if status does not represent a valid HTTP status code. + */ + public CommandResponseMessage(final String replyId, final String correlationId, final String status, + final Buffer payload) { + super(payload); + + Objects.requireNonNull(replyId); + Objects.requireNonNull(correlationId); + Objects.requireNonNull(status); + + this.replyId = replyId; + this.correlationId = correlationId; + + final Integer statusCode = parseStatus(status); + validateStatus(statusCode); + this.status = statusCode; + + } + + /** + * Gets the target address of the response. + * + * @param tenantId The tenant. + * @param deviceId The device that sends the response. + * @return The command reply address as expected by Hono'sCommand & Control API. + */ + public String getTargetAddress(final String tenantId, final String deviceId) { + return String.format("%s/%s/%s/%s", CommandConstants.NORTHBOUND_COMMAND_RESPONSE_ENDPOINT, tenantId, deviceId, + replyId); + } + + /** + * Gets the correlation id of the response. + * + * @return The correlation id. + */ + public String getCorrelationId() { + return correlationId; + } + + /** + * Gets the status code indicating the outcome of the request. + * + * @return The code. + */ + public int getStatus() { + return status; + } + + @Override + public String toString() { + return "command response [replyId=" + replyId + + ", correlationId=" + correlationId + + ", status=" + status + + "]"; + } + + private void validateStatus(final int status) { + if ((status < 200) || (status >= 600)) { + throw new IllegalArgumentException("status is invalid"); + } + } + + private Integer parseStatus(final String status) { + try { + return Integer.parseInt(status); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(e); + } + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/DownstreamMessage.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/DownstreamMessage.java new file mode 100644 index 00000000..b1f02f0b --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/DownstreamMessage.java @@ -0,0 +1,89 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream; + +import java.util.HashMap; +import java.util.Map; + +import io.vertx.core.buffer.Buffer; + +/** + * Data to be sent to Hono's AMQP adapter. + */ +public abstract class DownstreamMessage { + + private final Buffer payload; + private Map applicationProperties; + private String contentType; + + /** + * Creates an instance. + * + * @param payload The payload to be used. + * @throws NullPointerException if payload is {@code null}. + */ + public DownstreamMessage(final Buffer payload) { + this.payload = payload; + } + + /** + * Gets the payload of the message as a byte array. + * + * @return The payload. + */ + public byte[] getPayload() { + return (payload == null) ? null : payload.getBytes(); + } + + /** + * Gets the content type of the message payload. + * + * @return The type or {@code null} if the content type is unknown. + */ + public String getContentType() { + return contentType; + } + + /** + * Sets the content type of the message payload. + * + * @param contentType The type or {@code null} if the content type is unknown. + */ + public void setContentType(final String contentType) { + this.contentType = contentType; + } + + /** + * Adds the given property to the AMQP application properties to be added to a message. + * + * @param key The key of the property. + * @param value The value of the property. + */ + public void addApplicationProperty(final String key, final Object value) { + if (applicationProperties == null) { + applicationProperties = new HashMap<>(); + } + applicationProperties.put(key, value); + } + + /** + * Gets the application properties to be added to a message. + * + * @return The application properties. + */ + public Map getApplicationProperties() { + return applicationProperties; + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/EventMessage.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/EventMessage.java new file mode 100644 index 00000000..85a39062 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/EventMessage.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream; + +import java.util.Objects; + +import io.vertx.core.buffer.Buffer; + +/** + * This class holds required data of an event message. + */ +public final class EventMessage extends DownstreamMessage { + + /** + * Creates an instance. + * + * @param payload The payload to be used. + * @throws NullPointerException if payload is {@code null}. + */ + public EventMessage(final Buffer payload) { + super(Objects.requireNonNull(payload)); + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/TelemetryMessage.java b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/TelemetryMessage.java new file mode 100644 index 00000000..d6b71d1c --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/downstream/TelemetryMessage.java @@ -0,0 +1,48 @@ +/******************************************************************************* + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream; + +import java.util.Objects; + +import io.vertx.core.buffer.Buffer; + +/** + * This class holds required data of a telemetry message. + */ +public final class TelemetryMessage extends DownstreamMessage { + + private final boolean waitForOutcome; + + /** + * Creates an instance. + * + * @param payload The payload to be used. + * @param waitForOutcome True if the sender should wait for the outcome of the send operation. + * @throws NullPointerException if payload is {@code null}. + */ + public TelemetryMessage(final Buffer payload, final boolean waitForOutcome) { + super(Objects.requireNonNull(payload)); + + this.waitForOutcome = waitForOutcome; + } + + /** + * Returns if the result of the sending should be waited for. + * + * @return {@code true} if the sender should wait for the outcome of the send operation. + */ + public boolean shouldWaitForOutcome() { + return waitForOutcome; + } +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java new file mode 100644 index 00000000..e4ed1222 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java @@ -0,0 +1,925 @@ +/** + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.amqp.transport.Target; +import org.apache.qpid.proton.message.Message; +import org.apache.qpid.proton.message.impl.MessageImpl; +import org.eclipse.hono.client.HonoConnection; +import org.eclipse.hono.client.device.amqp.AmqpAdapterClientFactory; +import org.eclipse.hono.client.device.amqp.CommandResponder; +import org.eclipse.hono.client.device.amqp.EventSender; +import org.eclipse.hono.client.device.amqp.TelemetrySender; +import org.eclipse.hono.client.device.amqp.internal.AmqpAdapterClientCommandConsumer; +import org.eclipse.hono.client.device.amqp.internal.AmqpAdapterClientCommandResponseSender; +import org.eclipse.hono.client.device.amqp.internal.AmqpAdapterClientEventSenderImpl; +import org.eclipse.hono.client.device.amqp.internal.AmqpAdapterClientTelemetrySenderImpl; +import org.eclipse.hono.config.ClientConfigProperties; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.CommandSubscription; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttDownstreamContext; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttProtocolGatewayConfig; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.CommandResponseMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.DownstreamMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.TelemetryMessage; +import org.eclipse.hono.util.MessageHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; + +import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import io.opentracing.Tracer; +import io.opentracing.noop.NoopTracerFactory; +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.ClientAuth; +import io.vertx.core.json.JsonObject; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.core.net.PfxOptions; +import io.vertx.junit5.Timeout; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.mqtt.MqttEndpoint; +import io.vertx.mqtt.MqttServerOptions; +import io.vertx.proton.ProtonQoS; +import io.vertx.proton.ProtonReceiver; +import io.vertx.proton.ProtonSender; + +/** + * Verifies behavior of {@link AbstractMqttProtocolGateway}. + */ +@ExtendWith(VertxExtension.class) +@Timeout(value = 10, timeUnit = TimeUnit.SECONDS) +public class AbstractMqttProtocolGatewayTest { + + private final ClientConfigProperties amqpClientConfig = new ClientConfigProperties(); + private final Vertx vertx = mock(Vertx.class); + private final ProtonSender protonSender = mockProtonSender(); + private final NetServer netServer = mock(NetServer.class); + private final AmqpAdapterClientFactory amqpAdapterClientFactory = mock(AmqpAdapterClientFactory.class); + private Consumer commandHandler; + + /** + * Sets up common fixture. + */ + @BeforeEach + public void setUp() { + final HonoConnection connection = mockHonoConnection(vertx, amqpClientConfig); + + when(amqpAdapterClientFactory.connect()).thenReturn(Future.succeededFuture()); + + final Future eventSender = AmqpAdapterClientEventSenderImpl + .createWithAnonymousLinkAddress(connection, TestMqttProtocolGateway.TENANT_ID, s -> { + }); + when(amqpAdapterClientFactory.getOrCreateEventSender()).thenReturn(eventSender); + + final Future telemetrySender = AmqpAdapterClientTelemetrySenderImpl + .createWithAnonymousLinkAddress(connection, TestMqttProtocolGateway.TENANT_ID, s -> { + }); + when(amqpAdapterClientFactory.getOrCreateTelemetrySender()).thenReturn(telemetrySender); + + final Future commandResponseSender = AmqpAdapterClientCommandResponseSender + .createWithAnonymousLinkAddress(connection, TestMqttProtocolGateway.TENANT_ID, s -> { + }); + when(amqpAdapterClientFactory.getOrCreateCommandResponseSender()).thenReturn(commandResponseSender); + + when(amqpAdapterClientFactory.createDeviceSpecificCommandConsumer(anyString(), any())) + .thenAnswer(invocation -> { + final Consumer msgHandler = invocation.getArgument(1); + setCommandHandler(msgHandler); + return AmqpAdapterClientCommandConsumer.create(connection, TestMqttProtocolGateway.TENANT_ID, + TestMqttProtocolGateway.DEVICE_ID, + (protonDelivery, message) -> msgHandler.accept(message)); + }); + + when(vertx.createNetServer(any())).thenReturn(netServer); + when(netServer.listen(anyInt(), anyString(), ProtocolGatewayTestHelper.anyHandler())).then(invocation -> { + final Handler> handler = invocation.getArgument(2); + handler.handle(Future.succeededFuture(netServer)); + return netServer; + }); + + doAnswer(invocation -> { + final Promise handler = invocation.getArgument(0); + handler.complete(); + return null; + }).when(netServer).close(ProtocolGatewayTestHelper.anyHandler()); + + } + + /** + * Verifies that the MqttServerOptions for the MQTT server are taken from the given the server configuration. + */ + @Test + public void testMqttServerConfigWithoutTls() { + final int port = 1111; + final String bindAddress = "127.0.0.127"; + + final MqttProtocolGatewayConfig config = new MqttProtocolGatewayConfig(); + config.setBindAddress(bindAddress); + config.setPort(port); + + // GIVEN a protocol gateway with properties configured + final TestMqttProtocolGateway gateway = createGateway(config); + + // WHEN the server options are created + final MqttServerOptions serverOptions = gateway.getMqttServerOptions(); + + // THEN the server options contain the configured properties... + assertThat(serverOptions.getHost()).isEqualTo(bindAddress); + assertThat(serverOptions.getPort()).isEqualTo(port); + + // ...AND TLS has not been enabled + assertThat(serverOptions.isSsl()).isFalse(); + assertThat(serverOptions.getKeyCertOptions()).isNull(); + assertThat(serverOptions.getTrustOptions()).isNull(); + assertThat(serverOptions.getClientAuth()).isEqualTo(ClientAuth.NONE); + + } + + /** + * Verifies that the MqttServerOptions for the MQTT server are configured correctly for the use of TLS when setting + * the corresponding properties in the server configuration. + */ + @Test + public void testMqttServerConfigWithTls() { + + final String keyStorePath = "src/test/resources/emptyKeyStoreFile.p12"; + final List enabledProtocols = Arrays.asList("TLSv1", "TLSv1.1", "TLSv1.2"); + + // GIVEN a protocol gateway with TLS configured + final MqttProtocolGatewayConfig config = new MqttProtocolGatewayConfig(); + config.setKeyStorePath(keyStorePath); // sets KeyCertOptions + config.setSecureProtocols(enabledProtocols); + config.setSni(true); + + final TestMqttProtocolGateway gateway = createGateway(config); + + // WHEN the server options are created + final MqttServerOptions serverOptions = gateway.getMqttServerOptions(); + + // THEN the TLS configuration is correct + assertThat(serverOptions.isSsl()).isTrue(); + assertThat(serverOptions.getKeyCertOptions()).isEqualTo(new PfxOptions().setPath(keyStorePath)); + + final LinkedHashSet expectedEnabledSecureProtocols = new LinkedHashSet<>(enabledProtocols); + assertThat(serverOptions.getEnabledSecureTransportProtocols()).isEqualTo(expectedEnabledSecureProtocols); + assertThat(serverOptions.isSni()).isTrue(); + + // and not trust options have been set + assertThat(serverOptions.getTrustOptions()).isNull(); + assertThat(serverOptions.getClientAuth()).isEqualTo(ClientAuth.NONE); + } + + /** + * Verifies that the MqttServerOptions for the MQTT server are configured correctly for the use of client + * certificate based authentication when setting the corresponding properties in the server configuration. + */ + @Test + public void testMqttServerConfigWithTlsAndClientAuth() { + + final String keyStorePath = "src/test/resources/emptyKeyStoreFile.p12"; + final String trustStorePath = "src/test/resources/emptyTrustStoreFile.pem"; + final List enabledProtocols = Arrays.asList("TLSv1", "TLSv1.1", "TLSv1.2"); + + // GIVEN a protocol gateway with client certificate based authentication (and TLS) configured + final MqttProtocolGatewayConfig config = new MqttProtocolGatewayConfig(); + config.setKeyStorePath(keyStorePath); // sets KeyCertOptions + config.setTrustStorePath(trustStorePath); // sets TrustOptions + config.setSecureProtocols(enabledProtocols); + config.setSni(true); + + final TestMqttProtocolGateway gateway = createGateway(config); + + // WHEN the server options are created + final MqttServerOptions serverOptions = gateway.getMqttServerOptions(); + + // THEN the trust options are set from the configuration and client certificate based authentication is enabled + assertThat(serverOptions.getTrustOptions()).isEqualTo(new PemTrustOptions().addCertPath(trustStorePath)); + assertThat(serverOptions.getClientAuth()).isEqualTo(ClientAuth.REQUEST); + + assertThat(serverOptions.isSsl()).isTrue(); + assertThat(serverOptions.getKeyCertOptions()).isEqualTo(new PfxOptions().setPath(keyStorePath)); + } + + /** + * Verifies that an MQTT server is bound to the configured port and address during startup and + * {@link AbstractMqttProtocolGateway#afterStartup(Promise)} is being invoked. + * + * @param ctx The helper to use for running async tests on vertx. + */ + @Test + public void testStartup(final VertxTestContext ctx) { + final int port = 1111; + final String bindAddress = "127.0.0.127"; + + // GIVEN a protocol gateway with port and address configured + final MqttProtocolGatewayConfig serverConfig = new MqttProtocolGatewayConfig(); + serverConfig.setPort(port); + serverConfig.setBindAddress(bindAddress); + + final TestMqttProtocolGateway gateway = createGateway(serverConfig); + + // WHEN starting the verticle + final Promise startupTracker = Promise.promise(); + gateway.start(startupTracker); + + // THEN the server starts to listen on the configured port and the start method completes + startupTracker.future().onComplete(ctx.succeeding(s -> { + + ctx.verify(() -> { + verify(netServer).listen(eq(port), eq(bindAddress), ProtocolGatewayTestHelper.anyHandler()); + assertThat(gateway.isStartupComplete()).isTrue(); + }); + ctx.completeNow(); + })); + + } + + /** + * Verifies that an MQTT server is bound to the configured port and address during startup and + * {@link AbstractMqttProtocolGateway#afterStartup(Promise)} is being invoked. + * + * @param ctx The helper to use for running async tests on vertx. + */ + @Test + public void testServerStopSucceeds(final VertxTestContext ctx) { + + // GIVEN a started protocol gateway + final TestMqttProtocolGateway gateway = createGateway(); + + final Promise startupTracker = Promise.promise(); + gateway.start(startupTracker); + + startupTracker.future().onComplete(ctx.succeeding(v -> { + + // WHEN stopping the verticle + final Promise stopTracker = Promise.promise(); + gateway.stop(stopTracker); + + stopTracker.future().onComplete(ctx.succeeding(ok -> { + + // THEN the MQTT server is closed and the shutdown completes + ctx.verify(() -> { + assertThat(gateway.isShutdownStarted()).isTrue(); + verify(netServer).close(ProtocolGatewayTestHelper.anyHandler()); + }); + ctx.completeNow(); + })); + })); + + } + + /** + * Verifies that the authentication with valid username and password succeeds. + */ + @Test + public void testConnectWithUsernamePasswordSucceeds() { + + // GIVEN a protocol gateway + final AbstractMqttProtocolGateway gateway = createGateway(); + + // WHEN connecting with known credentials + final MqttEndpoint mqttEndpoint = ProtocolGatewayTestHelper.connectMqttEndpoint(gateway, + TestMqttProtocolGateway.DEVICE_USERNAME, + TestMqttProtocolGateway.DEVICE_PASSWORD); + + // THEN the connection is accepted + verify(mqttEndpoint).accept(false); + + } + + /** + * Verifies that the authentication with invalid username fails. + */ + @Test + public void testAuthenticationWithWrongUsernameFails() { + + // GIVEN a protocol gateway + final TestMqttProtocolGateway gateway = createGateway(); + + // WHEN connecting with an unknown user + final MqttEndpoint mqttEndpoint = ProtocolGatewayTestHelper.connectMqttEndpoint(gateway, + "unknown-user", + TestMqttProtocolGateway.DEVICE_PASSWORD); + + // THEN the connection is rejected + verify(mqttEndpoint).reject(MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED); + + } + + /** + * Verifies that the authentication with invalid password fails. + */ + @Test + public void testAuthenticationWithWrongPasswordFails() { + + // GIVEN a protocol gateway + final TestMqttProtocolGateway gateway = createGateway(); + + // WHEN connecting with an invalid password + final MqttEndpoint mqttEndpoint = ProtocolGatewayTestHelper.connectMqttEndpoint(gateway, + TestMqttProtocolGateway.DEVICE_USERNAME, "wrong-password"); + + // THEN the connection is rejected + verify(mqttEndpoint).reject(MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED); + + } + + /** + * Verifies that the authentication with a valid client certificate succeeds. + */ + @Test + public void testConnectWithClientCertSucceeds() { + + final X509Certificate deviceCertificate = ProtocolGatewayTestHelper.createCertificate(); + + // GIVEN a protocol gateway configured with a trust anchor + final TestMqttProtocolGateway gateway = new TestMqttProtocolGateway(amqpClientConfig, + new MqttProtocolGatewayConfig(), vertx, amqpAdapterClientFactory) { + + @Override + protected Future> getTrustAnchors(final List certificates) { + // verification will always succeed because the client certificate is used as its own trust anchor + return Future.succeededFuture(Collections.singleton(new TrustAnchor(deviceCertificate, null))); + } + }; + + // WHEN connecting with a client certificate that can be validated by the trust anchor + final MqttEndpoint endpoint = ProtocolGatewayTestHelper.connectMqttEndpointWithClientCertificate(gateway, + deviceCertificate); + + // THEN the connection is accepted + verify(endpoint).accept(false); + } + + /** + * Verifies that the authentication with an invalid client certificate fails. + */ + @Test + public void testAuthenticationWithClientCertFailsIfTrustAnchorDoesNotMatch() { + + // GIVEN a protocol gateway configured with a trust anchor + final TestMqttProtocolGateway gateway = new TestMqttProtocolGateway(amqpClientConfig, + new MqttProtocolGatewayConfig(), vertx, amqpAdapterClientFactory) { + + @Override + protected Future> getTrustAnchors(final List certificates) { + // verification will fail because the certificate used for the trust anchor has nothing to do with the + // client certificate + final X509Certificate newCertificate = ProtocolGatewayTestHelper.createCertificate(); + return Future.succeededFuture(Collections.singleton(new TrustAnchor(newCertificate, null))); + } + }; + + // WHEN connecting with a client certificate that can NOT be validated by the trust anchor + final X509Certificate deviceCertificate = ProtocolGatewayTestHelper.createCertificate(); + final MqttEndpoint endpoint = ProtocolGatewayTestHelper + .connectMqttEndpointWithClientCertificate(gateway, deviceCertificate); + + // THEN the connection is rejected + verify(endpoint).reject(MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED); + } + + /** + * Verifies that the MQTT connection fails if the Hono instance is not available. + */ + @Test + public void testConnectFailsWhenGatewayCouldNotConnect() { + + // GIVEN a protocol gateway where establishing a connection to Hono's AMQP adapter fails + when(amqpAdapterClientFactory.connect()).thenReturn(Future.failedFuture("Connect failed")); + + final TestMqttProtocolGateway gateway = createGateway(); + + // WHEN a device connects + final MqttEndpoint endpoint = connectTestDevice(gateway); + + // THEN the connection is rejected + verify(endpoint).reject(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE); + } + + /** + * Verifies that the credentials for the gateway provided by the implementation of + * {@link AbstractMqttProtocolGateway} are used to configure the connection to the AMQP adapter, if no credentials + * are provided in the client configuration. + */ + @Test + public void testConnectWithGatewayCredentialsResolvedDynamicallySucceeds() { + + // GIVEN a protocol gateway where the AMQP config does NOT contain credentials ... + // ... and where the gateway credentials are resolved by the implementation + final ClientConfigProperties configWithoutCredentials = new ClientConfigProperties(); + final AbstractMqttProtocolGateway gateway = new TestMqttProtocolGateway(configWithoutCredentials, + new MqttProtocolGatewayConfig(), vertx, amqpAdapterClientFactory) { + + @Override + AmqpAdapterClientFactory createTenantClientFactory(final String tenantId, + final ClientConfigProperties clientConfig) { + + // THEN the AMQP connection is authenticated with the provided credentials... + assertThat(clientConfig.getUsername()).isEqualTo(GW_USERNAME); + assertThat(clientConfig.getPassword()).isEqualTo(GW_PASSWORD); + + // ... and not with the credentials from the configuration + assertThat(clientConfig.getUsername()).isNotEqualTo(configWithoutCredentials.getUsername()); + assertThat(clientConfig.getPassword()).isNotEqualTo(configWithoutCredentials.getPassword()); + + return super.createTenantClientFactory(tenantId, clientConfig); + } + }; + + // WHEN the gateway connects + connectTestDevice(gateway); + + } + + /** + * Verifies that the credentials for the gateway provided by the client configuration are used to configure the + * connection to the AMQP adapter and take precedence over the ones provided by the implementation of + * {@link AbstractMqttProtocolGateway}. + */ + @Test + public void testConfiguredCredentialsTakePrecedenceOverImplementation() { + + final String username = "a-user"; + final String password = "a-password"; + final ClientConfigProperties configWithCredentials = new ClientConfigProperties(); + configWithCredentials.setUsername(username); + configWithCredentials.setPassword(password); + + // GIVEN a protocol gateway where the AMQP config does contains credentials + final AbstractMqttProtocolGateway gateway = new TestMqttProtocolGateway(configWithCredentials, + new MqttProtocolGatewayConfig(), vertx, amqpAdapterClientFactory) { + + @Override + AmqpAdapterClientFactory createTenantClientFactory(final String tenantId, + final ClientConfigProperties clientConfig) { + + // THEN the AMQP connection is authenticated with the configured credentials... + assertThat(clientConfig.getUsername()).isEqualTo(username); + assertThat(clientConfig.getPassword()).isEqualTo(password); + + // ... and not with the credentials from the implementation + assertThat(clientConfig.getUsername()).isNotEqualTo(GW_USERNAME); + assertThat(clientConfig.getPassword()).isNotEqualTo(GW_PASSWORD); + + return super.createTenantClientFactory(tenantId, clientConfig); + } + }; + + // WHEN the gateway connects + connectTestDevice(gateway); + + } + + /** + * Verifies that the downstream message constructed in + * {@link AbstractMqttProtocolGateway#onPublishedMessage(MqttDownstreamContext)} is set completely into the AMQP + * message sent downstream. + */ + @Test + public void testDownstreamMessage() { + + final String payload = "payload1"; + final String topic = "topic/1"; + + final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + // GIVEN a protocol gateway with a MQTT endpoint connected + final TestMqttProtocolGateway gateway = createGateway(); + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + // WHEN sending a MQTT message + ProtocolGatewayTestHelper.sendMessage(mqttEndpoint, Buffer.buffer(payload), topic); + + // THEN the AMQP message contains the payload, application properties and content type + verify(protonSender).send(messageCaptor.capture(), any()); + + final Message amqpMessage = messageCaptor.getValue(); + + assertThat(MessageHelper.getPayloadAsString(amqpMessage)).isEqualTo(payload); + + assertThat(MessageHelper.getApplicationProperty(amqpMessage.getApplicationProperties(), + TestMqttProtocolGateway.KEY_APPLICATION_PROPERTY_TOPIC, String.class)).isEqualTo(topic); + assertThat(MessageHelper.getDeviceId(amqpMessage)).isEqualTo(TestMqttProtocolGateway.DEVICE_ID); + + assertThat(amqpMessage.getContentType()).isEqualTo(TestMqttProtocolGateway.CONTENT_TYPE); + } + + /** + * Verifies that an event message is being sent to the right address. + */ + @Test + public void testEventMessage() { + + final String payload = "payload1"; + final String topic = "topic/1"; + + final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + // GIVEN a protocol gateway that sends every MQTT publish message as an event downstream and a connected MQTT + // endpoint + final TestMqttProtocolGateway gateway = createGateway(); + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + // WHEN sending a MQTT message + ProtocolGatewayTestHelper.sendMessage(mqttEndpoint, Buffer.buffer(payload), topic); + + // THEN the AMQP message contains the correct address + verify(protonSender).send(messageCaptor.capture(), any()); + + final String expectedAddress = "event/" + TestMqttProtocolGateway.TENANT_ID + "/" + + TestMqttProtocolGateway.DEVICE_ID; + assertThat(messageCaptor.getValue().getAddress()).isEqualTo(expectedAddress); + + } + + /** + * Verifies that a telemetry message is being sent to the right address. + */ + @Test + public void testTelemetryMessage() { + + final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + // GIVEN a protocol gateway that sends every MQTT publish messages as telemetry messages downstream and a + // connected MQTT endpoint + final TestMqttProtocolGateway gateway = new TestMqttProtocolGateway(amqpClientConfig, + new MqttProtocolGatewayConfig(), vertx, amqpAdapterClientFactory) { + + @Override + protected Future onPublishedMessage(final MqttDownstreamContext ctx) { + return Future.succeededFuture(new TelemetryMessage(ctx.message().payload(), false)); + } + }; + + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + // WHEN sending a MQTT message + ProtocolGatewayTestHelper.sendMessage(mqttEndpoint, Buffer.buffer("payload"), "topic"); + + // THEN the AMQP message contains the correct address + verify(protonSender).send(messageCaptor.capture(), any()); + + final String expectedAddress = "telemetry/" + TestMqttProtocolGateway.TENANT_ID + "/" + + TestMqttProtocolGateway.DEVICE_ID; + assertThat(messageCaptor.getValue().getAddress()).isEqualTo(expectedAddress); + } + + /** + * Verifies that a command response message is constructed correctly and being sent to the right address. + */ + @Test + public void testCommandResponse() { + + final String payload = "payload1"; + final String correlationId = "the-correlation-id"; + final String replyId = "the-reply-id"; + final Integer status = 200; + + final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + // GIVEN a protocol gateway that sends every MQTT publish messages as command response messages downstream and a + // connected MQTT endpoint + final TestMqttProtocolGateway gateway = new TestMqttProtocolGateway(amqpClientConfig, + new MqttProtocolGatewayConfig(), vertx, amqpAdapterClientFactory) { + + @Override + protected Future onPublishedMessage(final MqttDownstreamContext ctx) { + return Future.succeededFuture( + new CommandResponseMessage(replyId, correlationId, status.toString(), ctx.message().payload())); + } + }; + + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + // WHEN sending a MQTT message + ProtocolGatewayTestHelper.sendMessage(mqttEndpoint, Buffer.buffer(payload), "topic/123"); + + // THEN the AMQP message contains the required values and the correct address + verify(protonSender).send(messageCaptor.capture(), any()); + + final Message amqpMessage = messageCaptor.getValue(); + + assertThat(MessageHelper.getPayloadAsString(amqpMessage)).isEqualTo(payload); + assertThat(amqpMessage.getCorrelationId()).isEqualTo(correlationId); + assertThat(MessageHelper.getApplicationProperty(amqpMessage.getApplicationProperties(), + MessageHelper.APP_PROPERTY_STATUS, Integer.class)).isEqualTo(status); + + final String expectedAddress = "command_response/" + TestMqttProtocolGateway.TENANT_ID + "/" + + TestMqttProtocolGateway.DEVICE_ID + "/" + replyId; + assertThat(amqpMessage.getAddress()).isEqualTo(expectedAddress); + } + + /** + * Verifies that subscriptions are stored and acknowledged correctly. + */ + @Test + public void testCommandSubscription() { + + @SuppressWarnings("unchecked") + final ArgumentCaptor> subscribeAckCaptor = ArgumentCaptor.forClass(List.class); + + // GIVEN a protocol gateway and a connected MQTT endpoint + final TestMqttProtocolGateway gateway = createGateway(); + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + // WHEN sending a subscribe message with multiple topic filters + final int subscribeMsgId = ProtocolGatewayTestHelper.subscribe(mqttEndpoint, + new MqttTopicSubscription(TestMqttProtocolGateway.FILTER1, MqttQoS.AT_LEAST_ONCE), + new MqttTopicSubscription(TestMqttProtocolGateway.FILTER2, MqttQoS.AT_MOST_ONCE)); + + // THEN the subscriptions are acknowledged correctly... + verify(mqttEndpoint).subscribeAcknowledge(eq(subscribeMsgId), subscribeAckCaptor.capture()); + + assertThat(subscribeAckCaptor.getValue()).isEqualTo(Arrays.asList(MqttQoS.AT_LEAST_ONCE, MqttQoS.AT_MOST_ONCE)); + + // ... and the internal map is correct as well + final Map subscriptions = gateway.getCommandSubscriptionsManager() + .getSubscriptions(); + + assertThat(subscriptions.size()).isEqualTo(2); + assertThat(subscriptions.get(TestMqttProtocolGateway.FILTER1).getQos()).isEqualTo(MqttQoS.AT_LEAST_ONCE); + assertThat(subscriptions.get(TestMqttProtocolGateway.FILTER2).getQos()).isEqualTo(MqttQoS.AT_MOST_ONCE); + } + + /** + * Verifies that when a device tries to subscribe using the unsupported QoS 2, then it is only granted QoS 1. + */ + @Test + public void testCommandSubscriptionDowngradesQoS2() { + + @SuppressWarnings("unchecked") + final ArgumentCaptor> subscribeAckCaptor = ArgumentCaptor.forClass(List.class); + + // GIVEN a protocol gateway and a connected MQTT endpoint + final TestMqttProtocolGateway gateway = createGateway(); + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + // WHEN sending a subscribe message that requests QoS 2 + final int subscribeMsgId = ProtocolGatewayTestHelper.subscribe(mqttEndpoint, + new MqttTopicSubscription(TestMqttProtocolGateway.FILTER1, MqttQoS.EXACTLY_ONCE)); + + // THEN the QoS is downgraded to QoS 1 in the acknowledgement... + verify(mqttEndpoint).subscribeAcknowledge(eq(subscribeMsgId), subscribeAckCaptor.capture()); + + assertThat(subscribeAckCaptor.getValue()).isEqualTo(Collections.singletonList(MqttQoS.AT_LEAST_ONCE)); + + // ... and in the internal map as well + final Map subscriptions = gateway.getCommandSubscriptionsManager() + .getSubscriptions(); + + assertThat(subscriptions.get(TestMqttProtocolGateway.FILTER1).getQos()).isEqualTo(MqttQoS.AT_LEAST_ONCE); + } + + /** + * Verifies that no subscriptions are being accepted for unsupported topic filters. + */ + @Test + public void testCommandSubscriptionFailsForInvalidTopicFilter() { + + @SuppressWarnings("unchecked") + final ArgumentCaptor> subscribeAckCaptor = ArgumentCaptor.forClass(List.class); + + // GIVEN a protocol gateway and a connected MQTT endpoint + final TestMqttProtocolGateway gateway = createGateway(); + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + // WHEN sending a subscribe message with a topic filter that the gateway does not provide + final int subscribeMsgId = ProtocolGatewayTestHelper.subscribe(mqttEndpoint, + TestMqttProtocolGateway.FILTER_INVALID); + + // THEN the subscription is acknowledged correctly as a failure... + verify(mqttEndpoint).subscribeAcknowledge(eq(subscribeMsgId), subscribeAckCaptor.capture()); + + assertThat(subscribeAckCaptor.getValue()).isEqualTo(Collections.singletonList(MqttQoS.FAILURE)); + + // ... and it is not contained in the internal map + final Map subscriptions = gateway.getCommandSubscriptionsManager() + .getSubscriptions(); + + assertThat(subscriptions.isEmpty()).isTrue(); + } + + /** + * Verifies that when the protocol gateway receives a command for a subscribed device, then the command is published + * via MQTT to the device. + */ + @Test + public void testReceiveCommand() { + final String subject = "the/subject"; + final String replyTo = "the/reply/address"; + final String correlationId = "the-correlation-id"; + final String messageId = "the-message-id"; + + final Message commandMessage = new MessageImpl(); + MessageHelper.setJsonPayload(commandMessage, TestMqttProtocolGateway.PAYLOAD); + commandMessage.setSubject(subject); + commandMessage.setReplyTo(replyTo); + commandMessage.setCorrelationId(correlationId); + commandMessage.setMessageId(messageId); + + final JsonObject expected = new JsonObject() + .put(TestMqttProtocolGateway.KEY_SUBJECT, subject) + .put(TestMqttProtocolGateway.KEY_REPLY_TO, replyTo) + .put(TestMqttProtocolGateway.KEY_CORRELATION_ID, correlationId) + .put(TestMqttProtocolGateway.KEY_MESSAGE_ID, messageId) + .put(TestMqttProtocolGateway.KEY_COMMAND_PAYLOAD, TestMqttProtocolGateway.PAYLOAD) + .put(TestMqttProtocolGateway.KEY_CONTENT_TYPE, TestMqttProtocolGateway.CONTENT_TYPE); + + // GIVEN a protocol gateway and a connected MQTT endpoint with a command subscription + final TestMqttProtocolGateway gateway = createGateway(); + + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + ProtocolGatewayTestHelper.subscribe(mqttEndpoint, TestMqttProtocolGateway.FILTER1); + + // WHEN receiving the command + commandHandler.accept(commandMessage); + + // THEN the command is published to the MQTT endpoint + final ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Buffer.class); + + verify(mqttEndpoint).publish(eq(TestMqttProtocolGateway.COMMAND_TOPIC), payloadCaptor.capture(), + eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false), any()); + + assertThat(payloadCaptor.getValue().toJsonObject()).isEqualTo(expected); + + } + + /** + * Verifies that subscriptions are remove when unsubscribing. + */ + @Test + public void testUnsubscribe() { + + // GIVEN a protocol gateway and a connected MQTT endpoint with two subscriptions + final TestMqttProtocolGateway gateway = createGateway(); + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + ProtocolGatewayTestHelper.subscribe(mqttEndpoint, + new MqttTopicSubscription(TestMqttProtocolGateway.FILTER1, MqttQoS.AT_LEAST_ONCE), + new MqttTopicSubscription(TestMqttProtocolGateway.FILTER2, MqttQoS.AT_MOST_ONCE)); + + // WHEN sending an unsubscribe message containing one of the topic filters and a third onw + final int unsubscribeMsgId = ProtocolGatewayTestHelper.unsubscribe(mqttEndpoint, + TestMqttProtocolGateway.FILTER2, TestMqttProtocolGateway.FILTER_INVALID); + + // THEN the message is acknowledged + verify(mqttEndpoint).unsubscribeAcknowledge(eq(unsubscribeMsgId)); + + // ... and the internal map is correct as well + final Map subscriptions = gateway.getCommandSubscriptionsManager() + .getSubscriptions(); + assertThat(subscriptions.size()).isEqualTo(1); + assertThat(subscriptions.containsKey(TestMqttProtocolGateway.FILTER1)).isTrue(); + assertThat(subscriptions.containsKey(TestMqttProtocolGateway.FILTER2)).isFalse(); + + } + + /** + * Verifies that when the MQTT connections is being closed, the subscriptions are removed and + * {@link AbstractMqttProtocolGateway#onDeviceConnectionClose(MqttEndpoint)} is invoked. + */ + @Test + public void testConnectionClose() { + + // GIVEN a protocol gateway and a connected MQTT endpoint with subscriptions + final TestMqttProtocolGateway gateway = createGateway(); + final MqttEndpoint mqttEndpoint = connectTestDevice(gateway); + + ProtocolGatewayTestHelper.subscribe(mqttEndpoint, + new MqttTopicSubscription(TestMqttProtocolGateway.FILTER1, MqttQoS.AT_LEAST_ONCE), + new MqttTopicSubscription(TestMqttProtocolGateway.FILTER2, MqttQoS.AT_MOST_ONCE)); + + // WHEN the connection is closed + mqttEndpoint.close(); + + // THEN the subscriptions are removed ... + assertThat(gateway.getCommandSubscriptionsManager().getSubscriptions().isEmpty()).isTrue(); + + // ... and the callback onDeviceConnectionClose() has been invoked + assertThat(gateway.isConnectionClosed()).isTrue(); + } + + /** + * Creates a mocked Hono connection that returns a Noop Tracer. + * + * @param vertx The vert.x instance to use. + * @param props The client properties to use. + * @return The connection. + */ + private HonoConnection mockHonoConnection(final Vertx vertx, final ClientConfigProperties props) { + + final Tracer tracer = NoopTracerFactory.create(); + final HonoConnection connection = mock(HonoConnection.class); + when(connection.getVertx()).thenReturn(vertx); + when(connection.getConfig()).thenReturn(props); + when(connection.getTracer()).thenReturn(tracer); + when(connection.isConnected(anyLong())).thenReturn(Future.succeededFuture()); + when(connection.executeOnContext(ProtocolGatewayTestHelper.anyHandler())).then(invocation -> { + final Promise result = Promise.promise(); + final Handler> handler = invocation.getArgument(0); + handler.handle(result.future()); + return result.future(); + }); + + when(connection.getTracer()).thenReturn(tracer); + when(connection.createSender(any(), any(), any())).thenReturn(Future.succeededFuture(protonSender)); + + final ProtonReceiver receiver = mockProtonReceiver(); + when(connection.createReceiver(anyString(), any(), any(), any())).thenReturn(Future.succeededFuture(receiver)); + + return connection; + } + + /** + * Creates a mocked Proton sender which always returns {@code true} when its isOpen method is called. + * + * @return The mocked sender. + */ + private ProtonSender mockProtonSender() { + + final ProtonSender sender = mock(ProtonSender.class); + when(sender.isOpen()).thenReturn(Boolean.TRUE); + when(sender.getQoS()).thenReturn(ProtonQoS.AT_LEAST_ONCE); + when(sender.getTarget()).thenReturn(mock(Target.class)); + + return sender; + } + + /** + * Creates a mocked Proton receiver which always returns {@code true} when its isOpen method is called. + * + * @return The mocked receiver. + */ + public ProtonReceiver mockProtonReceiver() { + + final ProtonReceiver receiver = mock(ProtonReceiver.class); + when(receiver.isOpen()).thenReturn(Boolean.TRUE); + when(receiver.getSource()).thenReturn(new Source()); + + return receiver; + } + + private void setCommandHandler(final Consumer msgHandler) { + commandHandler = msgHandler; + } + + private MqttEndpoint connectTestDevice(final AbstractMqttProtocolGateway gateway) { + return ProtocolGatewayTestHelper.connectMqttEndpoint(gateway, + TestMqttProtocolGateway.DEVICE_USERNAME, + TestMqttProtocolGateway.DEVICE_PASSWORD); + } + + private TestMqttProtocolGateway createGateway() { + return createGateway(new MqttProtocolGatewayConfig()); + } + + private TestMqttProtocolGateway createGateway(final MqttProtocolGatewayConfig gatewayServerConfig) { + return new TestMqttProtocolGateway(amqpClientConfig, gatewayServerConfig, vertx, amqpAdapterClientFactory); + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/ProtocolGatewayTestHelper.java b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/ProtocolGatewayTestHelper.java new file mode 100644 index 00000000..1be0cda9 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/ProtocolGatewayTestHelper.java @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.concurrent.ThreadLocalRandom; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; + +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; + +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.SelfSignedCertificate; +import io.vertx.mqtt.MqttAuth; +import io.vertx.mqtt.MqttEndpoint; +import io.vertx.mqtt.messages.MqttPublishMessage; +import io.vertx.mqtt.messages.MqttSubscribeMessage; +import io.vertx.mqtt.messages.MqttUnsubscribeMessage; +import io.vertx.mqtt.messages.impl.MqttSubscribeMessageImpl; +import io.vertx.mqtt.messages.impl.MqttUnsubscribeMessageImpl; + +/** + * Support for mocking MQTT connections to a {@link AbstractMqttProtocolGateway}. + **/ +public final class ProtocolGatewayTestHelper { + + private ProtocolGatewayTestHelper() { + } + + /** + * Creates a mocked MQTT endpoint and connects it to the given protocol gateway by calling the endpoint handler. + * Authenticates with the given username and password. + * + * @param gateway The protocol gateway to connect to. + * @param username The username. + * @param password The password. + * @return The connected and authenticated endpoint mock. + */ + public static MqttEndpoint connectMqttEndpoint(final AbstractMqttProtocolGateway gateway, + final String username, + final String password) { + + final MqttEndpoint endpoint = createMqttEndpoint(); + + when(endpoint.auth()).thenReturn(new MqttAuth(username, password)); + + gateway.handleEndpointConnection(endpoint); + return endpoint; + } + + /** + * Creates a mocked MQTT endpoint and connects it to the given protocol gateway by calling the endpoint handler. + * Authenticates with the given client certificate. + * + * @param gateway The protocol gateway to connect to. + * @param deviceCertificate The X.509 client certificate. + * @return The connected and authenticated endpoint mock. + */ + public static MqttEndpoint connectMqttEndpointWithClientCertificate(final AbstractMqttProtocolGateway gateway, + final X509Certificate deviceCertificate) { + + final MqttEndpoint endpoint = createMqttEndpoint(); + + when(endpoint.isSsl()).thenReturn(true); + + final SSLSession sslSession = mock(SSLSession.class); + try { + when(sslSession.getPeerCertificates()).thenReturn(new Certificate[] { deviceCertificate }); + } catch (SSLPeerUnverifiedException e) { + throw new RuntimeException("this should not be possible", e); + } + when(endpoint.sslSession()).thenReturn(sslSession); + + gateway.handleEndpointConnection(endpoint); + return endpoint; + } + + /** + * Simulates sending a MQTT subscribe message by invoking the subscribe handler, that has been set on the given mock + * endpoint during the connection establishment in one of the "connect..." methods in this class. + *

+ * The given topics are all subscribed with QoS "AT_LEAST_ONCE". + * + * @param endpoint The connected endpoint mock. + * @param topicFilters The topic filters to subscribe for. + * @return A random message id. + * + * @see #connectMqttEndpoint(AbstractMqttProtocolGateway, String, String) + * @see #connectMqttEndpointWithClientCertificate(AbstractMqttProtocolGateway, X509Certificate) + */ + public static int subscribe(final MqttEndpoint endpoint, final String... topicFilters) { + + final MqttTopicSubscription[] mqttTopicSubscriptions = Arrays.stream(topicFilters) + .map(topic -> new MqttTopicSubscription(topic, MqttQoS.AT_LEAST_ONCE)) + .toArray(MqttTopicSubscription[]::new); + + return subscribe(endpoint, mqttTopicSubscriptions); + } + + /** + * Simulates sending a MQTT subscribe message by invoking the subscribe handler, that has been set on the given mock + * endpoint during the connection establishment in one of the "connect..." methods in this class. + * + * @param endpoint The connected endpoint mock. + * @param subscriptions The topic subscriptions to subscribe for. + * @return A random message id. + * + * @see #connectMqttEndpoint(AbstractMqttProtocolGateway, String, String) + * @see #connectMqttEndpointWithClientCertificate(AbstractMqttProtocolGateway, X509Certificate) + */ + public static int subscribe(final MqttEndpoint endpoint, final MqttTopicSubscription... subscriptions) { + + final ArgumentCaptor> captor = argumentCaptorHandler(); + verify(endpoint).subscribeHandler(captor.capture()); + + final int messageId = newRandomMessageId(); + captor.getValue().handle(new MqttSubscribeMessageImpl(messageId, Arrays.asList(subscriptions))); + + return messageId; + } + + /** + * Simulates sending a MQTT unsubscribe message by invoking the unsubscribe handler, that has been set on the given + * mock endpoint during the connection establishment in one of the "connect..." methods in this class. + * + * @param endpoint The connected endpoint mock. + * @param topics The topic filters to unsubscribe. + * @return A random message id. + * + * @see #connectMqttEndpoint(AbstractMqttProtocolGateway, String, String) + * @see #connectMqttEndpointWithClientCertificate(AbstractMqttProtocolGateway, X509Certificate) + */ + public static int unsubscribe(final MqttEndpoint endpoint, final String... topics) { + + final ArgumentCaptor> captor = argumentCaptorHandler(); + verify(endpoint).unsubscribeHandler(captor.capture()); + + final int messageId = newRandomMessageId(); + captor.getValue().handle(new MqttUnsubscribeMessageImpl(messageId, Arrays.asList(topics))); + + return messageId; + } + + /** + * Simulates sending a MQTT publish message by invoking the publish handler, that has been set on the given mock + * endpoint during the connection establishment in one of the "connect..." methods in this class. + * + * @param endpoint The connected endpoint mock. + * @param payload The payload of the message. + * @param topic The topic of the message. + * + * @see #connectMqttEndpoint(AbstractMqttProtocolGateway, String, String) + * @see #connectMqttEndpointWithClientCertificate(AbstractMqttProtocolGateway, X509Certificate) + */ + public static void sendMessage(final MqttEndpoint endpoint, final Buffer payload, final String topic) { + + final ArgumentCaptor> captor = argumentCaptorHandler(); + verify(endpoint).publishHandler(captor.capture()); + + final MqttPublishMessage mqttMessage = mock(MqttPublishMessage.class); + when(mqttMessage.payload()).thenReturn(payload); + when(mqttMessage.topicName()).thenReturn(topic); + + captor.getValue().handle(mqttMessage); + } + + /** + * Returns a self signed certificate. + * + * @return A new X.509 certificate. + */ + public static X509Certificate createCertificate() { + final SelfSignedCertificate selfSignedCert = SelfSignedCertificate.create("eclipse.org"); + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate(new FileInputStream(selfSignedCert.certificatePath())); + } catch (CertificateException | FileNotFoundException e) { + throw new RuntimeException("Generating self signed cert failed", e); + } + } + + /** + * Matches any handler of given type, excluding nulls. + * + * @param The handler type. + * @return The value returned by {@link ArgumentMatchers#any(Class)}. + */ + public static Handler anyHandler() { + @SuppressWarnings("unchecked") + final Handler result = ArgumentMatchers.any(Handler.class); + return result; + } + + /** + * Argument captor for a handler. + * + * @param The handler type. + * @return The value returned by {@link ArgumentCaptor#forClass(Class)}. + */ + public static ArgumentCaptor> argumentCaptorHandler() { + @SuppressWarnings("unchecked") + final ArgumentCaptor> result = ArgumentCaptor.forClass(Handler.class); + return result; + } + + private static MqttEndpoint createMqttEndpoint() { + + final MqttEndpoint endpoint = mock(MqttEndpoint.class); + when(endpoint.isConnected()).thenReturn(true); + when(endpoint.clientIdentifier()).thenReturn("the-client-id"); + addCloseHandlerToEndpoint(endpoint); + + return endpoint; + } + + /** + * When the endpoint is closed, the close handler is invoked. + */ + private static void addCloseHandlerToEndpoint(final MqttEndpoint endpoint) { + when(endpoint.closeHandler(anyHandler())).then(invocation -> { + final Handler handler = invocation.getArgument(0); + doAnswer(s -> { + when(endpoint.isConnected()).thenReturn(false); + handler.handle(null); + return null; + }).when(endpoint).close(); + return null; + }); + } + + private static int newRandomMessageId() { + return ThreadLocalRandom.current().nextInt(); + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/TestMqttProtocolGateway.java b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/TestMqttProtocolGateway.java new file mode 100644 index 00000000..76036cd4 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/TestMqttProtocolGateway.java @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2020 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.hono.gateway.sdk.mqtt2amqp; + +import java.security.cert.X509Certificate; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.hono.auth.Device; +import org.eclipse.hono.client.device.amqp.AmqpAdapterClientFactory; +import org.eclipse.hono.config.ClientConfigProperties; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.Command; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.CommandSubscriptionsManager; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.Credentials; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttCommandContext; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttDownstreamContext; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttProtocolGatewayConfig; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.DownstreamMessage; +import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.EventMessage; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.mqtt.MqttEndpoint; + +/** + * A {@link AbstractMqttProtocolGateway} implementation for testing purposes. It handles only one device. + */ +class TestMqttProtocolGateway extends AbstractMqttProtocolGateway { + + public static final String DEVICE_USERNAME = "device-user"; + public static final String DEVICE_PASSWORD = "device-password"; + public static final String TENANT_ID = "the-tenant"; + public static final String DEVICE_ID = "the-device-id"; + public static final Device DEVICE = new Device(TENANT_ID, DEVICE_ID); + + public static final String GW_USERNAME = "gw@tenant2"; + public static final String GW_PASSWORD = "gw-secret"; + + public static final JsonObject PAYLOAD = new JsonObject("{\"the-key\": \"the-value\"}"); + public static final String CONTENT_TYPE = "application/json"; + public static final String COMMAND_TOPIC = "the/command/topic"; + public static final String FILTER1 = "topic/FILTER1/#"; + public static final String FILTER2 = "topic/FILTER2/#"; + public static final String FILTER_INVALID = "unknown/#"; + public static final String KEY_COMMAND_PAYLOAD = "command-payload"; + public static final String KEY_SUBJECT = "subject"; + public static final String KEY_REPLY_TO = "reply-to"; + public static final String KEY_CORRELATION_ID = "correlation-id"; + public static final String KEY_MESSAGE_ID = "message-id"; + public static final String KEY_CONTENT_TYPE = "content-type"; + public static final String KEY_APPLICATION_PROPERTIES = "application-properties"; + public static final String KEY_APPLICATION_PROPERTY_TOPIC = "topic"; + + private final AtomicBoolean startupComplete = new AtomicBoolean(); + private final AtomicBoolean shutdownStarted = new AtomicBoolean(); + private final AtomicBoolean connectionClosed = new AtomicBoolean(); + private final AmqpAdapterClientFactory amqpAdapterClientFactory; + + private CommandSubscriptionsManager commandSubscriptionsManager; + + TestMqttProtocolGateway(final ClientConfigProperties clientConfigProperties, + final MqttProtocolGatewayConfig mqttProtocolGatewayConfig, final Vertx vertx, + final AmqpAdapterClientFactory amqpAdapterClientFactory) { + super(clientConfigProperties, mqttProtocolGatewayConfig); + this.amqpAdapterClientFactory = amqpAdapterClientFactory; + super.vertx = vertx; + } + + /** + * Checks if the startup completed. + * + * @return {@code true} if {@link AbstractMqttProtocolGateway#afterStartup(Promise)} has been invoked. + */ + public boolean isStartupComplete() { + return startupComplete.get(); + } + + /** + * Checks if the shutdown has been initiated. + * + * @return {@code true} if {@link AbstractMqttProtocolGateway#beforeShutdown(Promise)} has been invoked. + */ + public boolean isShutdownStarted() { + return shutdownStarted.get(); + } + + /** + * Checks if the connection to a device has been closed. + * + * @return {@code true} if {@link AbstractMqttProtocolGateway#onDeviceConnectionClose(MqttEndpoint)} has been + * invoked. + */ + public boolean isConnectionClosed() { + return connectionClosed.get(); + } + + /** + * Return the command subscription manager for the test device. + * + * @return The command subscription manager that has been created during the establishment of the device connection. + */ + public CommandSubscriptionsManager getCommandSubscriptionsManager() { + return commandSubscriptionsManager; + } + + @Override + AmqpAdapterClientFactory createTenantClientFactory(final String tenantId, + final ClientConfigProperties clientConfig) { + return amqpAdapterClientFactory; + } + + @Override + protected Future authenticateDevice(final String username, final String password, + final String clientId) { + if (DEVICE_USERNAME.equals(username) && DEVICE_PASSWORD.equals(password)) { + return Future.succeededFuture(DEVICE); + } else { + return Future.failedFuture("auth failed"); + } + } + + @Override + protected boolean isTopicFilterValid(final String topicFilter, final String tenantId, final String deviceId, + final String clientId) { + return FILTER1.equals(topicFilter) || FILTER2.equals(topicFilter); + } + + @Override + protected Future onPublishedMessage(final MqttDownstreamContext ctx) { + final EventMessage message = new EventMessage(ctx.message().payload()); + message.addApplicationProperty(KEY_APPLICATION_PROPERTY_TOPIC, ctx.topic()); + message.setContentType(CONTENT_TYPE); + + return Future.succeededFuture(message); + } + + @Override + protected Command onCommandReceived(final MqttCommandContext ctx) { + final JsonObject payload = new JsonObject(); + payload.put(KEY_COMMAND_PAYLOAD, ctx.getPayload().toJson()); + payload.put(KEY_SUBJECT, ctx.getSubject()); + payload.put(KEY_REPLY_TO, ctx.getReplyTo()); + payload.put(KEY_CORRELATION_ID, ctx.getCorrelationId()); + payload.put(KEY_MESSAGE_ID, ctx.getMessageId()); + payload.put(KEY_CONTENT_TYPE, ctx.getContentType()); + + if (ctx.getApplicationProperties() != null) { + payload.put(KEY_APPLICATION_PROPERTIES, new JsonObject(ctx.getApplicationProperties().getValue())); + + } + + return new Command(COMMAND_TOPIC, FILTER1, payload.toBuffer()); + } + + @Override + protected Future authenticateClientCertificate(final X509Certificate deviceCertificate) { + return Future.succeededFuture(DEVICE); + } + + @Override + protected Future provideGatewayCredentials(final String tenantId) { + return Future.succeededFuture(new Credentials(GW_USERNAME, GW_PASSWORD)); + } + + @Override + protected void afterStartup(final Promise startPromise) { + startupComplete.compareAndSet(false, true); + super.afterStartup(startPromise); + } + + @Override + protected void beforeShutdown(final Promise stopPromise) { + shutdownStarted.compareAndSet(false, true); + super.beforeShutdown(stopPromise); + } + + @Override + CommandSubscriptionsManager createCommandHandler(final Vertx vertx) { + commandSubscriptionsManager = super.createCommandHandler(vertx); + return commandSubscriptionsManager; + } + + @Override + protected void onDeviceConnectionClose(final MqttEndpoint endpoint) { + connectionClosed.compareAndSet(false, true); + } + +} diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/emptyKeyStoreFile.p12 b/protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/emptyKeyStoreFile.p12 new file mode 100644 index 00000000..e69de29b diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/emptyTrustStoreFile.pem b/protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/emptyTrustStoreFile.pem new file mode 100644 index 00000000..e69de29b diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/logback.xml b/protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/logback.xml new file mode 100644 index 00000000..255aebc6 --- /dev/null +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/test/resources/logback.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + From 905444acbd42e0fa3e5614853473023ae4b453ff Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Tue, 23 Jun 2020 15:06:41 +0200 Subject: [PATCH 04/12] Add script to create tenant and devices for demonstrating Azure protocol gateway. Signed-off-by: Abel Buechner-Mihaljevic --- .../azure-mqtt-protocol-gateway/README.md | 28 +++++--- .../scripts/create_demo_devices.sh | 71 +++++++++++++++++++ .../eclipse/hono/gateway/azure/Config.java | 15 +++- .../main/resources/application-ssl.properties | 17 +++++ .../src/main/resources/application.properties | 28 ++++++++ .../src/main/resources/application.yml | 50 ------------- 6 files changed, 148 insertions(+), 61 deletions(-) create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/scripts/create_demo_devices.sh create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application-ssl.properties create mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.properties delete mode 100644 protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/README.md b/protocol-gateway/azure-mqtt-protocol-gateway/README.md index 88b8cd32..dd767447 100644 --- a/protocol-gateway/azure-mqtt-protocol-gateway/README.md +++ b/protocol-gateway/azure-mqtt-protocol-gateway/README.md @@ -48,6 +48,21 @@ Since there is only one device in this example implementation anyway, the creden ## Prerequisites +### Registering Devices + +The demo device and the gateway need to be registered in Hono's device registry. For the gateway credentials must be created. +The [Getting started](https://www.eclipse.org/hono/getting-started/#registering-devices) guide shows how to do this. + +Alternatively, the script `scripts/create_demo_devices.sh` can be used to register the devices and create credentials: +~~~sh +# in directory: protocol-gateway/azure-mqtt-protocol-gateway/scripts/ +bash create_demo_devices.sh +~~~ + +After completion the script prints the configuration properties. Copy the output into the +file `protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.properties`. + + ### Configuration The protocol gateway needs the configuration of: @@ -58,14 +73,8 @@ The protocol gateway needs the configuration of: By default, the gateway will connect to the AMQP adapter of the [Hono Sandbox](https://www.eclipse.org/hono/sandbox/). However, it can also be configured to connect to a local instance. -The default configuration can be found in the file `protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml` +The default configuration can be found in the file `protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.properties` and can be customized using [Spring Boot Configuration](https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config). - - -### Registering Devices - -The demo device to be used needs to be registered in Hono's device registry. -The [Getting started](https://www.eclipse.org/hono/getting-started/#registering-devices) guide shows how to register a device. ### Starting a Receiver @@ -96,8 +105,7 @@ on this port with TLS enabled and a demo certificate, run: ~~~sh # in directory: protocol-gateway/azure-mqtt-protocol-gateway/ -mvn clean install -java -jar target/azure-protocol-gateway-example-*-exec.jar --spring.profiles.active=ssl +mvn spring-boot:run -Dspring-boot.run.profiles=ssl ~~~ **NB** Do not forget to build the template project before, as shown above. @@ -105,7 +113,7 @@ With the [Eclipse Mosquitto](https://mosquitto.org/) command line client, for ex ~~~sh # in directory: protocol-gateway/azure-mqtt-protocol-gateway -mosquitto_pub -d -h localhost -p 8883 -i '4712' -u 'demo1' -P 'demo-secret' -t "devices/4712/messages/events/" -m "hello world" -V mqttv311 --cafile target/config/hono-demo-certs-jar/trusted-certs.pem -q 1 +mosquitto_pub -d -h localhost -p 8883 -i '4712' -u 'demo1' -P 'demo-secret' -t "devices/4712/messages/events/" -m "hello world" -V mqttv311 --cafile target/config/hono-demo-certs-jar/trusted-certs.pem ~~~ Existing hardware devices might need to be configured to accept the used certificate. diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/scripts/create_demo_devices.sh b/protocol-gateway/azure-mqtt-protocol-gateway/scripts/create_demo_devices.sh new file mode 100644 index 00000000..589dc3c9 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/scripts/create_demo_devices.sh @@ -0,0 +1,71 @@ +#!/bin/bash +#******************************************************************************* +# Copyright (c) 2020 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +#******************************************************************************* + +################################################################################ +# This simple shell script registers devices to be used to demonstrate a protocol gateway. +# It does the following: +# 1. create a new tenant (or use an existing one) +# 2. register a gateway device +# 3. create credentials for the gateway +# 4. register a demo device and configure it to use the gateway +################################################################################ + +################################################################################ +# CONFIGURATION +# +REGISTRY_IP=hono.eclipseprojects.io +DEVICE_TO_CREATE="4712" # If changed, change it in the mosquitto requests as well +GATEWAY_TO_CREATE="gw" +GATEWAY_PASSWORD="gw-secret" +# TENANT_TO_USE="" # Set this to use an existing tenant +################################################################################ + +set -e # exit script on error + +if [ -z "$GATEWAY_PASSWORD" ] || [ -z "$REGISTRY_IP" ] ; then + echo "ERROR: missing configuration. Exit." + exit 1 +fi + +echo "# Using device registry: ${REGISTRY_IP}" + +# register new tenant +if [ -z "$TENANT_TO_USE" ] ; then + TENANT_TO_USE=$(curl --fail -X POST http://$REGISTRY_IP:28080/v1/tenants 2>/dev/null | jq -r .id) + echo "# Registered new tenant: ${TENANT_TO_USE}" +else + echo "# Using configured tenant: ${TENANT_TO_USE}" +fi + +# register new gateway +GATEWAY_TO_CREATE=$(curl --fail -X POST http://$REGISTRY_IP:28080/v1/devices/$TENANT_TO_USE/$GATEWAY_TO_CREATE -d '{"enabled":true}' -H "Content-Type: application/json" 2>/dev/null | jq -r .id) + +# set credentials for gateway +curl --fail -X PUT -H "content-type: application/json" --data-binary '[{ + "type": "hashed-password", + "auth-id": "'$GATEWAY_TO_CREATE'", + "secrets": [{ "pwd-plain": "'$GATEWAY_PASSWORD'" }] +}]' http://$REGISTRY_IP:28080/v1/credentials/$TENANT_TO_USE/$GATEWAY_TO_CREATE +HONO_CLIENT_AMQP_PASSWORD=$GATEWAY_PASSWORD + +# register demo device +HONO_DEMO_DEVICE_DEVICE_ID=$(curl --fail -X POST http://$REGISTRY_IP:28080/v1/devices/$TENANT_TO_USE/$DEVICE_TO_CREATE -d '{"enabled":true,"via":["'$GATEWAY_TO_CREATE'"]}' -H "Content-Type: application/json" 2>/dev/null | jq -r .id) + +echo "# --- DONE ---" +echo "# Please copy the following properties into the configuration of your protocol gateway:" +echo +echo "hono.demo.device.deviceId=${HONO_DEMO_DEVICE_DEVICE_ID}" +echo "hono.client.amqp.password=${HONO_CLIENT_AMQP_PASSWORD}" +echo "hono.client.amqp.username=${GATEWAY_TO_CREATE}@${TENANT_TO_USE}" +echo "hono.demo.device.tenantId=${TENANT_TO_USE}" diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java index 4c2a6542..047b7abf 100644 --- a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java @@ -66,7 +66,20 @@ public ClientConfigProperties amqpClientConfig() { */ @Bean public AzureIotHubMqttGateway azureIotHubMqttGateway() { - return new AzureIotHubMqttGateway(amqpClientConfig(), mqttGatewayConfig(), demoDevice()); + final DemoDeviceConfiguration demoDeviceConfig = demoDevice(); + final ClientConfigProperties amqpClientConfig = amqpClientConfig(); + + if (demoDeviceConfig.getTenantId() == null || demoDeviceConfig.getDeviceId() == null) { + throw new IllegalArgumentException("Demo device is not configured."); + } + if (amqpClientConfig.getUsername() == null || amqpClientConfig.getPassword() == null) { + throw new IllegalArgumentException("Gateway credentials are not configured."); + } + if (amqpClientConfig.getHost() == null) { + throw new IllegalArgumentException("AMQP host is not configured."); + } + + return new AzureIotHubMqttGateway(amqpClientConfig, mqttGatewayConfig(), demoDeviceConfig); } /** diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application-ssl.properties b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application-ssl.properties new file mode 100644 index 00000000..31705d48 --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application-ssl.properties @@ -0,0 +1,17 @@ +#******************************************************************************* +# Copyright (c) 2020 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +#******************************************************************************* + +hono.server.mqtt.bindAddress=0.0.0.0 +hono.server.mqtt.port=8883 +hono.server.mqtt.keyPath=target/config/hono-demo-certs-jar/example-gateway-key.pem +hono.server.mqtt.certPath=target/config/hono-demo-certs-jar/example-gateway-cert.pem diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.properties b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.properties new file mode 100644 index 00000000..32ee99db --- /dev/null +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.properties @@ -0,0 +1,28 @@ +#******************************************************************************* +# Copyright (c) 2020 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +#******************************************************************************* + +logging.level.org.eclipse.hono.gateway=TRACE +logging.level.org.eclipse.hono.gateway.sdk=DEBUG + +hono.server.mqtt.bindAddress=0.0.0.0 +hono.server.mqtt.port=1883 +hono.client.amqp.host=hono.eclipseprojects.io +hono.client.amqp.port=5671 +hono.client.amqp.tlsEnabled=true +hono.demo.device.username=demo1 +hono.demo.device.password=demo-secret + +#hono.demo.device.deviceId=4712 +#hono.client.amqp.password=gw-secret +#hono.client.amqp.username=gw@DEFAULT_TENANT +#hono.demo.device.tenantId=DEFAULT_TENANT diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml deleted file mode 100644 index a18067ba..00000000 --- a/protocol-gateway/azure-mqtt-protocol-gateway/src/main/resources/application.yml +++ /dev/null @@ -1,50 +0,0 @@ -# -# Copyright (c) 2020 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Eclipse Public License 2.0 which is available at -# http://www.eclipse.org/legal/epl-2.0 -# -# SPDX-License-Identifier: EPL-2.0 -# - -logging: - level: - org.eclipse.hono.gateway: TRACE - org.eclipse.hono.gateway.sdk: DEBUG - -hono: - server: - mqtt: - bindAddress: 0.0.0.0 - port: 1883 - client: - amqp: - host: hono.eclipseprojects.io - port: 5671 - tlsEnabled: true - username: gw@DEFAULT_TENANT - password: gw-secret - demo: - device: - tenantId: DEFAULT_TENANT - deviceId: 4712 - username: demo1 - password: demo-secret - - ---- - -spring: - profiles: ssl - -hono: - server: - mqtt: - bindAddress: 0.0.0.0 - port: 8883 - keyPath: target/config/hono-demo-certs-jar/example-gateway-key.pem - certPath: target/config/hono-demo-certs-jar/example-gateway-cert.pem From 85ba2a305b24472e11334c29618bdcba6a7e936e Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Mon, 29 Jun 2020 14:11:49 +0200 Subject: [PATCH 05/12] [#3] Requested changes: corrections in readme files. Signed-off-by: Abel Buechner-Mihaljevic --- .../azure-mqtt-protocol-gateway/README.md | 13 +++++++------ .../mqtt-protocol-gateway-template/README.md | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/README.md b/protocol-gateway/azure-mqtt-protocol-gateway/README.md index dd767447..482e71e5 100644 --- a/protocol-gateway/azure-mqtt-protocol-gateway/README.md +++ b/protocol-gateway/azure-mqtt-protocol-gateway/README.md @@ -4,7 +4,7 @@ This Protocol Gateway shows how to use Hono's Protocol Gateway Template to imple The MQTT-API of "Azure IoT Hub" serves as a working example. Parts of its API are mapped to Hono's communication patterns. Full compatibility with the Azure IoT Hub is not a design goal of this example. It is supposed to behave similarly for -the "happy path", but cannot treat all errors or misuse in the same way as the former. +the "happy path", but cannot treat all errors or misuse in the same way as the former. Supported are the following types of messages: @@ -50,7 +50,7 @@ Since there is only one device in this example implementation anyway, the creden ### Registering Devices -The demo device and the gateway need to be registered in Hono's device registry. For the gateway credentials must be created. +The demo device and the gateway need to be registered in Hono's device registry. For the gateway, credentials must be created. The [Getting started](https://www.eclipse.org/hono/getting-started/#registering-devices) guide shows how to do this. Alternatively, the script `scripts/create_demo_devices.sh` can be used to register the devices and create credentials: @@ -69,7 +69,7 @@ The protocol gateway needs the configuration of: 1. the AMQP adapter of a running Hono instance to connect to 2. the MQTT server -3. the Demo-Device to use. +3. the demo device to use. By default, the gateway will connect to the AMQP adapter of the [Hono Sandbox](https://www.eclipse.org/hono/sandbox/). However, it can also be configured to connect to a local instance. @@ -113,7 +113,7 @@ With the [Eclipse Mosquitto](https://mosquitto.org/) command line client, for ex ~~~sh # in directory: protocol-gateway/azure-mqtt-protocol-gateway -mosquitto_pub -d -h localhost -p 8883 -i '4712' -u 'demo1' -P 'demo-secret' -t "devices/4712/messages/events/" -m "hello world" -V mqttv311 --cafile target/config/hono-demo-certs-jar/trusted-certs.pem +mosquitto_pub -d -h localhost -p 8883 -i '4712' -u 'demo1' -P 'demo-secret' -t "devices/4712/messages/events/" -m "hello world" -V mqttv311 --cafile target/config/hono-demo-certs-jar/trusted-certs.pem ~~~ Existing hardware devices might need to be configured to accept the used certificate. @@ -151,14 +151,15 @@ mosquitto_sub -v -h localhost -u "demo1" -P "demo-secret" -t 'devices/4712/messa mosquitto_sub -v -h localhost -u "demo1" -P "demo-secret" -t '$iothub/methods/POST/#' -q 1 ~~~ -When Mosquitto receives the command, in the terminal should appear something like this: +When Mosquitto receives the command, the output in the terminal should look like this: ~~~sh $iothub/methods/POST/setBrightness/?$rid=0100bba05d61-7027-4131-9a9d-30238b9ec9bb {"brightness": 87} ~~~ **Respond to a command** -To send a response the ID after `rid=` can be copied and pasted into a new terminal to send a response like this: +When sending a response, the request id must be added. The ID after `rid=` can be copied from the received message +and pasted into a new terminal to publish the response like this: ~~~sh export RID=0100bba05d61-7027-4131-9a9d-30238b9ec9bb mosquitto_pub -d -h localhost -u 'demo1' -P 'demo-secret' -t "\$iothub/methods/res/200/?\$rid=$RID" -m '{"success": true}' -q 1 diff --git a/protocol-gateway/mqtt-protocol-gateway-template/README.md b/protocol-gateway/mqtt-protocol-gateway-template/README.md index 5cc78eb1..7c29a843 100644 --- a/protocol-gateway/mqtt-protocol-gateway-template/README.md +++ b/protocol-gateway/mqtt-protocol-gateway-template/README.md @@ -96,7 +96,7 @@ can no longer simply be scaled horizontally. The template allows to use any of t Gateways must be registered as devices in Hono's device registry, and the corresponding credentials for authentication must be created. If all devices connecting to the protocol gateway belong to the same tenant, the credentials of the gateway device -can be configured in the `ClientConfigProperties`, which are passed in the constructor. +can be configured in the `ClientConfigProperties`, which are passed in the constructor of the class `AbstractMqttProtocolGateway`. If devices of different tenants are to be connected, the credentials must be determined dynamically, as described below. From b1a4b0eece7639e8061939946ed7bcd65b6b1b4d Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Tue, 30 Jun 2020 11:17:51 +0200 Subject: [PATCH 06/12] [#3] Improve tests: initialize mocks in setUp instead of field declaration. Signed-off-by: Abel Buechner-Mihaljevic --- .../azure/AzureIotHubMqttGatewayTest.java | 4 +-- .../AbstractMqttProtocolGatewayTest.java | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java index 8a4b2584..61572255 100644 --- a/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java +++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/test/java/org/eclipse/hono/gateway/azure/AzureIotHubMqttGatewayTest.java @@ -68,11 +68,10 @@ public class AzureIotHubMqttGatewayTest { private static final String directMessageTopicFilter = AzureIotHubMqttGateway.DIRECT_METHOD_TOPIC_FILTER; private final Device device = new Device(TENANT_ID, DEVICE_ID); - private final Message commandMessage = mock(Message.class); - private final Buffer payload = new JsonObject().put("a-key", "a-value").toBuffer(); private final DemoDeviceConfiguration demoDeviceConfig = new DemoDeviceConfiguration(); + private Message commandMessage; private AzureIotHubMqttGateway underTest; /** @@ -81,6 +80,7 @@ public class AzureIotHubMqttGatewayTest { @BeforeEach public void setUp() { + commandMessage = mock(Message.class); when(commandMessage.getBody()).thenReturn(new Data(new Binary(payload.getBytes()))); when(commandMessage.getMessageId()).thenReturn(MESSAGE_ID); when(commandMessage.getCorrelationId()).thenReturn(CORRELATION_ID); diff --git a/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java index e4ed1222..e5dcca78 100644 --- a/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java +++ b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java @@ -49,9 +49,6 @@ import org.eclipse.hono.client.device.amqp.internal.AmqpAdapterClientEventSenderImpl; import org.eclipse.hono.client.device.amqp.internal.AmqpAdapterClientTelemetrySenderImpl; import org.eclipse.hono.config.ClientConfigProperties; -import org.eclipse.hono.gateway.sdk.mqtt2amqp.CommandSubscription; -import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttDownstreamContext; -import org.eclipse.hono.gateway.sdk.mqtt2amqp.MqttProtocolGatewayConfig; import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.CommandResponseMessage; import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.DownstreamMessage; import org.eclipse.hono.gateway.sdk.mqtt2amqp.downstream.TelemetryMessage; @@ -93,11 +90,11 @@ @Timeout(value = 10, timeUnit = TimeUnit.SECONDS) public class AbstractMqttProtocolGatewayTest { - private final ClientConfigProperties amqpClientConfig = new ClientConfigProperties(); - private final Vertx vertx = mock(Vertx.class); - private final ProtonSender protonSender = mockProtonSender(); - private final NetServer netServer = mock(NetServer.class); - private final AmqpAdapterClientFactory amqpAdapterClientFactory = mock(AmqpAdapterClientFactory.class); + private ClientConfigProperties amqpClientConfig; + private Vertx vertx; + private ProtonSender protonSender; + private NetServer netServer; + private AmqpAdapterClientFactory amqpAdapterClientFactory; private Consumer commandHandler; /** @@ -105,10 +102,16 @@ public class AbstractMqttProtocolGatewayTest { */ @BeforeEach public void setUp() { - final HonoConnection connection = mockHonoConnection(vertx, amqpClientConfig); + amqpAdapterClientFactory = mock(AmqpAdapterClientFactory.class); + netServer = mock(NetServer.class); + vertx = mock(Vertx.class); + protonSender = mockProtonSender(); when(amqpAdapterClientFactory.connect()).thenReturn(Future.succeededFuture()); + amqpClientConfig = new ClientConfigProperties(); + final HonoConnection connection = mockHonoConnection(vertx, amqpClientConfig, protonSender); + final Future eventSender = AmqpAdapterClientEventSenderImpl .createWithAnonymousLinkAddress(connection, TestMqttProtocolGateway.TENANT_ID, s -> { }); @@ -849,9 +852,11 @@ public void testConnectionClose() { * * @param vertx The vert.x instance to use. * @param props The client properties to use. + * @param protonSender The proton sender to use. * @return The connection. */ - private HonoConnection mockHonoConnection(final Vertx vertx, final ClientConfigProperties props) { + private HonoConnection mockHonoConnection(final Vertx vertx, final ClientConfigProperties props, + final ProtonSender protonSender) { final Tracer tracer = NoopTracerFactory.create(); final HonoConnection connection = mock(HonoConnection.class); From 30a53634eb06b0918d7ff22eab960c1f9473e7d7 Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Tue, 30 Jun 2020 11:38:36 +0200 Subject: [PATCH 07/12] [#3] Update Hono version to 1.3.0-M3. Signed-off-by: Abel Buechner-Mihaljevic --- protocol-gateway/azure-mqtt-protocol-gateway/pom.xml | 2 +- protocol-gateway/mqtt-protocol-gateway-template/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol-gateway/azure-mqtt-protocol-gateway/pom.xml b/protocol-gateway/azure-mqtt-protocol-gateway/pom.xml index e63b2c09..fd748d00 100644 --- a/protocol-gateway/azure-mqtt-protocol-gateway/pom.xml +++ b/protocol-gateway/azure-mqtt-protocol-gateway/pom.xml @@ -53,7 +53,7 @@ ${maven.build.timestamp} 3.15.0 - 1.3.0-SNAPSHOT + 1.3.0-M3 0.0.1-SNAPSHOT 5.6.0 3.3.3 diff --git a/protocol-gateway/mqtt-protocol-gateway-template/pom.xml b/protocol-gateway/mqtt-protocol-gateway-template/pom.xml index b06490b7..508d65a5 100644 --- a/protocol-gateway/mqtt-protocol-gateway-template/pom.xml +++ b/protocol-gateway/mqtt-protocol-gateway-template/pom.xml @@ -49,7 +49,7 @@ ${maven.build.timestamp} 3.15.0 - 1.3.0-M2 + 1.3.0-M3 5.6.0 3.3.3 3.9.1 From 553e868d4efd0172336975f39a9c52715676a900 Mon Sep 17 00:00:00 2001 From: Abel Buechner-Mihaljevic Date: Tue, 7 Jul 2020 11:14:32 +0200 Subject: [PATCH 08/12] [#3] Add Maven plugins surefire and failsafe. Signed-off-by: Abel Buechner-Mihaljevic --- .../mqtt-protocol-gateway-template/pom.xml | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/protocol-gateway/mqtt-protocol-gateway-template/pom.xml b/protocol-gateway/mqtt-protocol-gateway-template/pom.xml index 508d65a5..c917205c 100644 --- a/protocol-gateway/mqtt-protocol-gateway-template/pom.xml +++ b/protocol-gateway/mqtt-protocol-gateway-template/pom.xml @@ -135,6 +135,33 @@ + + org.apache.maven.plugins + maven-javadoc-plugin + 3.1.0 + + all,-accessibility + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + + junit.jupiter.execution.parallel.enabled = true + junit.jupiter.execution.parallel.mode.default = same_thread + junit.jupiter.execution.parallel.mode.classes.default = concurrent + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.2 +