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
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..482e71e5
--- /dev/null
+++ b/protocol-gateway/azure-mqtt-protocol-gateway/README.md
@@ -0,0 +1,167 @@
+# "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
+
+### 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:
+
+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.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).
+
+
+### 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 spring-boot:run -Dspring-boot.run.profiles=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
+~~~
+
+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, 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**
+
+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
+~~~
+Note that the actual identifier from the command must be used.
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..fd748d00
--- /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-M3
+ 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/scripts/create_demo_devices.sh b/protocol-gateway/azure-mqtt-protocol-gateway/scripts/create_demo_devices.sh
new file mode 100755
index 00000000..68e75d60
--- /dev/null
+++ b/protocol-gateway/azure-mqtt-protocol-gateway/scripts/create_demo_devices.sh
@@ -0,0 +1,72 @@
+#!/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
+#
+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
+################################################################################
+
+REGISTRY_IP=${REGISTRY_IP:-hono.eclipseprojects.io}
+
+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/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..7e07e906
--- /dev/null
+++ b/protocol-gateway/azure-mqtt-protocol-gateway/src/main/java/org/eclipse/hono/gateway/azure/Config.java
@@ -0,0 +1,98 @@
+/*******************************************************************************
+ * 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() {
+ final ClientConfigProperties amqpClientConfig = new ClientConfigProperties();
+ amqpClientConfig.setServerRole("AMQP Adapter");
+ return amqpClientConfig;
+ }
+
+ /**
+ * Creates a new Azure IoT Hub protocol gateway instance.
+ *
+ * @return The new instance.
+ */
+ @Bean
+ public AzureIotHubMqttGateway azureIotHubMqttGateway() {
+ 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);
+ }
+
+ /**
+ * 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-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/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..61572255
--- /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 Buffer payload = new JsonObject().put("a-key", "a-value").toBuffer();
+ private final DemoDeviceConfiguration demoDeviceConfig = new DemoDeviceConfiguration();
+
+ private Message commandMessage;
+ private AzureIotHubMqttGateway underTest;
+
+ /**
+ * Sets up the fixture.
+ */
+ @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);
+ 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/README.md b/protocol-gateway/mqtt-protocol-gateway-template/README.md
new file mode 100644
index 00000000..7c29a843
--- /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 of the class `AbstractMqttProtocolGateway`.
+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 @@
+
+
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..c917205c
--- /dev/null
+++ b/protocol-gateway/mqtt-protocol-gateway-template/pom.xml
@@ -0,0 +1,242 @@
+
+
+
+
+ 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-M3
+ 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
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.8.1
+
+
+ ${java.level}
+ UTF-8
+
+
+
+ org.codehaus.mojo
+ animal-sniffer-maven-plugin
+ 1.18
+
+
+ verify_java8_compatibility
+ test
+
+ check
+
+
+
+
+
+ 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/**
+
+
+
+
+
+ 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..aa775d56
--- /dev/null
+++ b/protocol-gateway/mqtt-protocol-gateway-template/src/main/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGateway.java
@@ -0,0 +1,926 @@
+/*******************************************************************************
+ * 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:
+ *
+ *
invoke {@link #getTrustAnchors(List)}
+ *
validate the given certificate chain against the trust anchors
+ *
+ * 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 amqpAdapterClientFactory = clientFactoryPerTenant.get(tenantId);
+ if (amqpAdapterClientFactory != null) {
+ return amqpAdapterClientFactory.isConnected(clientConfig.getConnectTimeout());
+ } else {
+
+ final AmqpAdapterClientFactory factory = createTenantClientFactory(tenantId, clientConfig);
+ clientFactoryPerTenant.put(tenantId, factory);
+
+ return factory.connect()
+ .map(con -> {
+ log.debug("Connected to AMQP adapter");
+ return 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..e5dcca78
--- /dev/null
+++ b/protocol-gateway/mqtt-protocol-gateway-template/src/test/java/org/eclipse/hono/gateway/sdk/mqtt2amqp/AbstractMqttProtocolGatewayTest.java
@@ -0,0 +1,930 @@
+/**
+ * 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.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 ClientConfigProperties amqpClientConfig;
+ private Vertx vertx;
+ private ProtonSender protonSender;
+ private NetServer netServer;
+ private AmqpAdapterClientFactory amqpAdapterClientFactory;
+ private Consumer commandHandler;
+
+ /**
+ * Sets up common fixture.
+ */
+ @BeforeEach
+ public void setUp() {
+ 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 -> {
+ });
+ 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.
+ * @param protonSender The proton sender to use.
+ * @return The connection.
+ */
+ private HonoConnection mockHonoConnection(final Vertx vertx, final ClientConfigProperties props,
+ final ProtonSender protonSender) {
+
+ 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
+
+
+
+
+
+
+
+
+