From bf29e16df6fc1fb508f6bb5cbb4d9041f5cfde53 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Thu, 7 May 2026 23:10:24 +0200 Subject: [PATCH 1/9] Implement MQTT Client V5 and Topic Alias on Server V5 (vertx4 port) Port of upstream commit b7c3bd8 from client_mqtt5_master onto the vertx4 branch. Functional scope unchanged from the original commit: - Manage Publish v5 - Updated MqttEndpointImpl to handle PUBCOMP messages with reason codes. - Enhance authentication and subscription features - Modified MqttServerConnection to allow AUTH messages before CONNACK. - Added support for Wildcard and Shared Subscription properties in MqttConnAckMessage. - Implemented corresponding methods in MqttConnAckMessageImpl to retrieve new properties. - Added tests in Mqtt5ClientConnectTest for user properties in CONNECT packets. - Enhanced Mqtt5ClientDisconnectTest to verify server-initiated DISCONNECT handling. - Updated Mqtt5ClientPublishTest to check for maximum packet size enforcement. - Added tests in Mqtt5ClientSubscribeTest for handling of wildcard and shared subscriptions. - Created Mqtt5ClientWillTest to ensure correct encoding of will message properties. - Manage Server Redirect - Manage Subscription Identifier - Manage TopicAlias from Broker to Client Breaking Changes: - Removed willFlag client side (info derived from the presence of willTopic and willPayload). - Will options serialized as object in json. Adaptations for vertx4 / older Netty: - Coexistence of legacy Handler> overloads with the new Future-based v5 API (connect, disconnect, publish, unsubscribe). - MqttProperties. rewritten to MqttProperties.MqttPropertyType..value() because Netty 4.1.133 exposes property ids only via the enum. - String.isBlank() replaced with trim().isEmpty() (Java 8 compatibility). - List.of(...) / Map.of(...) replaced with Arrays.asList / Collections.singletonMap in tests. - connect(...) calls disambiguated with explicit (Map) null casts. - Imported io.vertx.core.net.* to keep JksOptions / PfxOptions / PemKeyCertOptions / PemTrustOptions visible. - Pulled MqttAuthenticationExchangeMessage / MqttAuthenticateReasonCode / MqttAuthenticationExchangeMessageImpl from master as transitive dependencies. Server-side AUTH feature is incomplete: only a stub handleAuth was added to MqttEndpointImpl, and Mqtt5ClientAuthTest was dropped. The full authentication-exchange API requires upstream commit 892e923, which is not part of this port. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci-client-mqtt5.yml | 26 + .../mqtt/MqttClientOptionsConverter.java | 98 +- .../mqtt/MqttClientWillOptionsConverter.java | 124 +++ src/main/java/io/vertx/mqtt/MqttClient.java | 188 ++++ .../java/io/vertx/mqtt/MqttClientOptions.java | 318 ++++-- .../io/vertx/mqtt/MqttClientWillOptions.java | 327 +++++++ .../java/io/vertx/mqtt/MqttException.java | 9 +- .../io/vertx/mqtt/impl/MqttClientImpl.java | 909 +++++++++++++++++- .../io/vertx/mqtt/impl/MqttEndpointImpl.java | 14 +- .../vertx/mqtt/impl/MqttServerConnection.java | 48 +- .../MqttAuthenticationExchangeMessage.java | 52 + .../mqtt/messages/MqttConnAckMessage.java | 211 +++- .../mqtt/messages/MqttSubAckMessage.java | 23 +- .../mqtt/messages/MqttUnsubAckMessage.java | 54 ++ .../codes/MqttAuthenticateReasonCode.java | 30 + ...MqttAuthenticationExchangeMessageImpl.java | 47 + .../messages/impl/MqttConnAckMessageImpl.java | 154 ++- .../messages/impl/MqttSubAckMessageImpl.java | 18 + .../impl/MqttUnsubAckMessageImpl.java | 43 + .../io/vertx/mqtt/it/Mqtt5ClientBaseIT.java | 58 ++ .../vertx/mqtt/it/Mqtt5ClientConnectIT.java | 110 +++ .../vertx/mqtt/it/Mqtt5ClientPublishIT.java | 135 +++ .../vertx/mqtt/it/Mqtt5ClientSubscribeIT.java | 153 +++ .../test/client/Mqtt5ClientConnectTest.java | 445 +++++++++ .../client/Mqtt5ClientDisconnectTest.java | 211 ++++ .../client/Mqtt5ClientFlowControlTest.java | 242 +++++ .../test/client/Mqtt5ClientPublishTest.java | 699 ++++++++++++++ .../test/client/Mqtt5ClientSubscribeTest.java | 395 ++++++++ .../Mqtt5ClientSubscriptionOptionsTest.java | 219 +++++ .../client/Mqtt5ClientTopicAliasTest.java | 200 ++++ .../client/Mqtt5ClientUnsubscribeTest.java | 225 +++++ .../mqtt/test/client/Mqtt5ClientWillTest.java | 172 ++++ .../test/client/Mqtt5ServerRedirectTest.java | 212 ++++ .../client/Mqtt5TopicAliasHandlingTest.java | 388 ++++++++ .../test/server/MqttServerBadClientTest.java | 2 +- .../mqtt/test/server/MqttServerWillTest.java | 75 -- 36 files changed, 6396 insertions(+), 238 deletions(-) create mode 100644 .github/workflows/ci-client-mqtt5.yml create mode 100644 src/main/generated/io/vertx/mqtt/MqttClientWillOptionsConverter.java create mode 100644 src/main/java/io/vertx/mqtt/MqttClientWillOptions.java create mode 100644 src/main/java/io/vertx/mqtt/messages/MqttAuthenticationExchangeMessage.java create mode 100644 src/main/java/io/vertx/mqtt/messages/MqttUnsubAckMessage.java create mode 100644 src/main/java/io/vertx/mqtt/messages/codes/MqttAuthenticateReasonCode.java create mode 100644 src/main/java/io/vertx/mqtt/messages/impl/MqttAuthenticationExchangeMessageImpl.java create mode 100644 src/main/java/io/vertx/mqtt/messages/impl/MqttUnsubAckMessageImpl.java create mode 100644 src/test/java/io/vertx/mqtt/it/Mqtt5ClientBaseIT.java create mode 100644 src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java create mode 100644 src/test/java/io/vertx/mqtt/it/Mqtt5ClientPublishIT.java create mode 100644 src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientConnectTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientDisconnectTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientPublishTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscribeTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscriptionOptionsTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientUnsubscribeTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientWillTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5TopicAliasHandlingTest.java diff --git a/.github/workflows/ci-client-mqtt5.yml b/.github/workflows/ci-client-mqtt5.yml new file mode 100644 index 00000000..b904ce12 --- /dev/null +++ b/.github/workflows/ci-client-mqtt5.yml @@ -0,0 +1,26 @@ +name: CI client_mqtt5_master + +on: + push: + branches: + - client_mqtt5_master + pull_request: + branches: + - client_mqtt5_master + +jobs: + CI: + name: Run tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + - name: Install JDK + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: temurin + - name: Run tests + run: mvn -s .github/maven-ci-settings.xml -q clean verify -B diff --git a/src/main/generated/io/vertx/mqtt/MqttClientOptionsConverter.java b/src/main/generated/io/vertx/mqtt/MqttClientOptionsConverter.java index d7010e3c..a3974860 100644 --- a/src/main/generated/io/vertx/mqtt/MqttClientOptionsConverter.java +++ b/src/main/generated/io/vertx/mqtt/MqttClientOptionsConverter.java @@ -25,6 +25,16 @@ static void fromJson(Iterable> json, MqttCli obj.setAckTimeout(((Number)member.getValue()).intValue()); } break; + case "authenticationData": + if (member.getValue() instanceof String) { + obj.setAuthenticationData(io.vertx.core.buffer.Buffer.buffer(BASE64_DECODER.decode((String)member.getValue()))); + } + break; + case "authenticationMethod": + if (member.getValue() instanceof String) { + obj.setAuthenticationMethod((String)member.getValue()); + } + break; case "autoAck": if (member.getValue() instanceof Boolean) { obj.setAutoAck((Boolean)member.getValue()); @@ -40,6 +50,11 @@ static void fromJson(Iterable> json, MqttCli obj.setAutoKeepAlive((Boolean)member.getValue()); } break; + case "autoServerRedirect": + if (member.getValue() instanceof Boolean) { + obj.setAutoServerRedirect((Boolean)member.getValue()); + } + break; case "cleanSession": if (member.getValue() instanceof Boolean) { obj.setCleanSession((Boolean)member.getValue()); @@ -65,44 +80,59 @@ static void fromJson(Iterable> json, MqttCli obj.setMaxMessageSize(((Number)member.getValue()).intValue()); } break; + case "maximumPacketSize": + if (member.getValue() instanceof Number) { + obj.setMaximumPacketSize(((Number)member.getValue()).longValue()); + } + break; case "password": if (member.getValue() instanceof String) { obj.setPassword((String)member.getValue()); } break; + case "receiveMaximum": + if (member.getValue() instanceof Number) { + obj.setReceiveMaximum(((Number)member.getValue()).intValue()); + } + break; case "recvByteBufAllocatorSize": if (member.getValue() instanceof Number) { obj.setRecvByteBufAllocatorSize(((Number)member.getValue()).intValue()); } break; - case "username": - if (member.getValue() instanceof String) { - obj.setUsername((String)member.getValue()); + case "requestProblemInformation": + if (member.getValue() instanceof Boolean) { + obj.setRequestProblemInformation((Boolean)member.getValue()); } break; - case "willFlag": + case "requestResponseInformation": if (member.getValue() instanceof Boolean) { - obj.setWillFlag((Boolean)member.getValue()); + obj.setRequestResponseInformation((Boolean)member.getValue()); } break; - case "willMessageBytes": - if (member.getValue() instanceof String) { - obj.setWillMessageBytes(io.vertx.core.buffer.Buffer.buffer(BASE64_DECODER.decode((String)member.getValue()))); + case "sessionExpireInterval": + if (member.getValue() instanceof Number) { + obj.setSessionExpireInterval(((Number)member.getValue()).longValue()); } break; - case "willQoS": + case "topicAliasMaximum": if (member.getValue() instanceof Number) { - obj.setWillQoS(((Number)member.getValue()).intValue()); + obj.setTopicAliasMaximum(((Number)member.getValue()).intValue()); } break; - case "willRetain": - if (member.getValue() instanceof Boolean) { - obj.setWillRetain((Boolean)member.getValue()); + case "username": + if (member.getValue() instanceof String) { + obj.setUsername((String)member.getValue()); } break; - case "willTopic": - if (member.getValue() instanceof String) { - obj.setWillTopic((String)member.getValue()); + case "version": + if (member.getValue() instanceof Number) { + obj.setVersion(((Number)member.getValue()).intValue()); + } + break; + case "willOptions": + if (member.getValue() instanceof JsonObject) { + obj.setWillOptions(new io.vertx.mqtt.MqttClientWillOptions((io.vertx.core.json.JsonObject)member.getValue())); } break; } @@ -115,9 +145,16 @@ static void toJson(MqttClientOptions obj, JsonObject json) { static void toJson(MqttClientOptions obj, java.util.Map json) { json.put("ackTimeout", obj.getAckTimeout()); + if (obj.getAuthenticationData() != null) { + json.put("authenticationData", BASE64_ENCODER.encodeToString(obj.getAuthenticationData().getBytes())); + } + if (obj.getAuthenticationMethod() != null) { + json.put("authenticationMethod", obj.getAuthenticationMethod()); + } json.put("autoAck", obj.isAutoAck()); json.put("autoGeneratedClientId", obj.isAutoGeneratedClientId()); json.put("autoKeepAlive", obj.isAutoKeepAlive()); + json.put("autoServerRedirect", obj.isAutoServerRedirect()); json.put("cleanSession", obj.isCleanSession()); if (obj.getClientId() != null) { json.put("clientId", obj.getClientId()); @@ -125,21 +162,34 @@ static void toJson(MqttClientOptions obj, java.util.Map json) { json.put("keepAliveInterval", obj.getKeepAliveInterval()); json.put("maxInflightQueue", obj.getMaxInflightQueue()); json.put("maxMessageSize", obj.getMaxMessageSize()); + if (obj.getMaximumPacketSize() != null) { + json.put("maximumPacketSize", obj.getMaximumPacketSize()); + } if (obj.getPassword() != null) { json.put("password", obj.getPassword()); } + if (obj.getReceiveMaximum() != null) { + json.put("receiveMaximum", obj.getReceiveMaximum()); + } json.put("recvByteBufAllocatorSize", obj.getRecvByteBufAllocatorSize()); + if (obj.getRequestProblemInformation() != null) { + json.put("requestProblemInformation", obj.getRequestProblemInformation()); + } + if (obj.getRequestResponseInformation() != null) { + json.put("requestResponseInformation", obj.getRequestResponseInformation()); + } + if (obj.getSessionExpireInterval() != null) { + json.put("sessionExpireInterval", obj.getSessionExpireInterval()); + } + if (obj.getTopicAliasMaximum() != null) { + json.put("topicAliasMaximum", obj.getTopicAliasMaximum()); + } if (obj.getUsername() != null) { json.put("username", obj.getUsername()); } - json.put("willFlag", obj.isWillFlag()); - if (obj.getWillMessageBytes() != null) { - json.put("willMessageBytes", BASE64_ENCODER.encodeToString(obj.getWillMessageBytes().getBytes())); - } - json.put("willQoS", obj.getWillQoS()); - json.put("willRetain", obj.isWillRetain()); - if (obj.getWillTopic() != null) { - json.put("willTopic", obj.getWillTopic()); + json.put("version", obj.getVersion()); + if (obj.getWillOptions() != null) { + json.put("willOptions", obj.getWillOptions().toJson()); } } } diff --git a/src/main/generated/io/vertx/mqtt/MqttClientWillOptionsConverter.java b/src/main/generated/io/vertx/mqtt/MqttClientWillOptionsConverter.java new file mode 100644 index 00000000..d93008a3 --- /dev/null +++ b/src/main/generated/io/vertx/mqtt/MqttClientWillOptionsConverter.java @@ -0,0 +1,124 @@ +package io.vertx.mqtt; + +import io.vertx.core.json.JsonObject; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.impl.JsonUtil; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Base64; + +/** + * Converter and mapper for {@link io.vertx.mqtt.MqttClientWillOptions}. + * NOTE: This class has been automatically generated from the {@link io.vertx.mqtt.MqttClientWillOptions} original class using Vert.x codegen. + */ +public class MqttClientWillOptionsConverter { + + + private static final Base64.Decoder BASE64_DECODER = JsonUtil.BASE64_DECODER; + private static final Base64.Encoder BASE64_ENCODER = JsonUtil.BASE64_ENCODER; + + static void fromJson(Iterable> json, MqttClientWillOptions obj) { + for (java.util.Map.Entry member : json) { + switch (member.getKey()) { + case "contentType": + if (member.getValue() instanceof String) { + obj.setContentType((String)member.getValue()); + } + break; + case "correlationData": + if (member.getValue() instanceof String) { + obj.setCorrelationData(io.vertx.core.buffer.Buffer.buffer(BASE64_DECODER.decode((String)member.getValue()))); + } + break; + case "messageBytes": + if (member.getValue() instanceof String) { + obj.setMessageBytes(io.vertx.core.buffer.Buffer.buffer(BASE64_DECODER.decode((String)member.getValue()))); + } + break; + case "payloadFormatIndicator": + if (member.getValue() instanceof Number) { + obj.setPayloadFormatIndicator(((Number)member.getValue()).intValue()); + } + break; + case "qos": + if (member.getValue() instanceof Number) { + obj.setQos(((Number)member.getValue()).intValue()); + } + break; + case "responseTopic": + if (member.getValue() instanceof String) { + obj.setResponseTopic((String)member.getValue()); + } + break; + case "retain": + if (member.getValue() instanceof Boolean) { + obj.setRetain((Boolean)member.getValue()); + } + break; + case "topic": + if (member.getValue() instanceof String) { + obj.setTopic((String)member.getValue()); + } + break; + case "userProperties": + if (member.getValue() instanceof JsonObject) { + java.util.Map map = new java.util.LinkedHashMap<>(); + ((Iterable>)member.getValue()).forEach(entry -> { + if (entry.getValue() instanceof String) + map.put(entry.getKey(), (String)entry.getValue()); + }); + obj.setUserProperties(map); + } + break; + case "userPropertys": + if (member.getValue() instanceof JsonObject) { + ((Iterable>)member.getValue()).forEach(entry -> { + if (entry.getValue() instanceof String) + obj.addUserProperty(entry.getKey(), (String)entry.getValue()); + }); + } + break; + case "willDelayInterval": + if (member.getValue() instanceof Number) { + obj.setWillDelayInterval(((Number)member.getValue()).longValue()); + } + break; + } + } + } + + static void toJson(MqttClientWillOptions obj, JsonObject json) { + toJson(obj, json.getMap()); + } + + static void toJson(MqttClientWillOptions obj, java.util.Map json) { + if (obj.getContentType() != null) { + json.put("contentType", obj.getContentType()); + } + if (obj.getCorrelationData() != null) { + json.put("correlationData", BASE64_ENCODER.encodeToString(obj.getCorrelationData().getBytes())); + } + if (obj.getMessageBytes() != null) { + json.put("messageBytes", BASE64_ENCODER.encodeToString(obj.getMessageBytes().getBytes())); + } + if (obj.getPayloadFormatIndicator() != null) { + json.put("payloadFormatIndicator", obj.getPayloadFormatIndicator()); + } + json.put("qos", obj.getQos()); + if (obj.getResponseTopic() != null) { + json.put("responseTopic", obj.getResponseTopic()); + } + json.put("retain", obj.isRetain()); + if (obj.getTopic() != null) { + json.put("topic", obj.getTopic()); + } + if (obj.getUserProperties() != null) { + JsonObject map = new JsonObject(); + obj.getUserProperties().forEach((key, value) -> map.put(key, value)); + json.put("userProperties", map); + } + if (obj.getWillDelayInterval() != null) { + json.put("willDelayInterval", obj.getWillDelayInterval()); + } + } +} diff --git a/src/main/java/io/vertx/mqtt/MqttClient.java b/src/main/java/io/vertx/mqtt/MqttClient.java index c8c38fed..b10e10d6 100644 --- a/src/main/java/io/vertx/mqtt/MqttClient.java +++ b/src/main/java/io/vertx/mqtt/MqttClient.java @@ -17,8 +17,11 @@ package io.vertx.mqtt; import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttSubscriptionOption; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; import io.vertx.codegen.annotations.Fluent; import io.vertx.codegen.annotations.VertxGen; +import io.vertx.codegen.annotations.GenIgnore; import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; @@ -26,8 +29,19 @@ import io.vertx.core.buffer.Buffer; import io.vertx.mqtt.impl.MqttClientImpl; import io.vertx.mqtt.messages.MqttConnAckMessage; +import io.vertx.mqtt.messages.MqttDisconnectMessage; +import io.vertx.mqtt.messages.MqttPubAckMessage; +import io.vertx.mqtt.messages.MqttPubCompMessage; +import io.vertx.mqtt.messages.MqttPubRecMessage; import io.vertx.mqtt.messages.MqttPublishMessage; import io.vertx.mqtt.messages.MqttSubAckMessage; +import io.vertx.mqtt.messages.MqttUnsubAckMessage; +import io.netty.handler.codec.mqtt.MqttProperties; +import io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubAckReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubRecReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubRelReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubCompReasonCode; import java.util.List; import java.util.Map; @@ -91,6 +105,17 @@ static MqttClient create(Vertx vertx) { * Like {@link #connect(int, String, String, Handler)} but returns a {@code Future} of the asynchronous result */ Future connect(int port, String host, String serverName); + + /** + * Connects to an MQTT server calling connectHandler after connection + * + * @param port port of the MQTT server + * @param host hostname/ip address of the MQTT server + * @param serverName the SNI server name + * @param userProperties Connect User Properties + * @return a future notified when the connect call ends + */ + Future connect(int port, String host, String serverName, Map userProperties); /** * Disconnects from the MQTT server @@ -108,6 +133,16 @@ static MqttClient create(Vertx vertx) { @Fluent MqttClient disconnect(Handler> disconnectHandler); + /** + * Disconnects from the MQTT server + * + * @param code reason code for the disconnect + * @param properties MQTT properties + * @return a {@code Future} of the asynchronous result + */ + @GenIgnore + Future disconnect(MqttDisconnectReasonCode code, MqttProperties properties); + /** * Sends the PUBLISH message to the remote MQTT server * @@ -134,6 +169,20 @@ static MqttClient create(Vertx vertx) { @Fluent MqttClient publish(String topic, Buffer payload, MqttQoS qosLevel, boolean isDup, boolean isRetain, Handler> publishSentHandler); + /** + * Sends the PUBLISH message to the remote MQTT server with MQTT 5.0 properties + * + * @param topic topic on which the message is published + * @param payload message payload + * @param qosLevel QoS level + * @param isDup if the message is a duplicate + * @param isRetain if the message needs to be retained + * @param properties MQTT 5.0 properties (e.g. message expiry, content type, response topic, user properties) + * @return a {@code Future} completed after PUBLISH packet sent with packetid (not when QoS 0) + */ + @GenIgnore + Future publish(String topic, Buffer payload, MqttQoS qosLevel, boolean isDup, boolean isRetain, MqttProperties properties); + /** * Sets a handler which will be called each time the publishing of a message has been completed. *

@@ -150,6 +199,45 @@ static MqttClient create(Vertx vertx) { @Fluent MqttClient publishCompletionHandler(Handler publishCompletionHandler); + /** + * Sets a handler which will be called each time a PUBACK is received from the server. + *

+ * MQTT 5.0: the handler receives the full typed message including reason code and properties. + * This handler fires alongside the existing {@link #publishCompletionHandler(Handler)}. + * + * @param handler handler called with the PUBACK message + * @return current MQTT client instance + */ + @Fluent + @GenIgnore(GenIgnore.PERMITTED_TYPE) + MqttClient publishAckMessageHandler(Handler handler); + + /** + * Sets a handler which will be called each time a PUBREC is received from the server. + *

+ * MQTT 5.0: the handler receives the full typed message including reason code and properties, + * before the client sends PUBREL. + * + * @param handler handler called with the PUBREC message + * @return current MQTT client instance + */ + @Fluent + @GenIgnore(GenIgnore.PERMITTED_TYPE) + MqttClient publishRecMessageHandler(Handler handler); + + /** + * Sets a handler which will be called each time a PUBCOMP is received from the server. + *

+ * MQTT 5.0: the handler receives the full typed message including reason code and properties. + * This handler fires alongside the existing {@link #publishCompletionHandler(Handler)}. + * + * @param handler handler called with the PUBCOMP message + * @return current MQTT client instance + */ + @Fluent + @GenIgnore(GenIgnore.PERMITTED_TYPE) + MqttClient publishCompMessageHandler(Handler handler); + /** * Sets a handler which will be called when the client does not receive a PUBACK or * PUBREC/PUBCOMP for a message published using QoS 1 or 2 respectively. @@ -169,6 +257,50 @@ static MqttClient create(Vertx vertx) { @Fluent MqttClient publishCompletionExpirationHandler(Handler publishCompletionExpirationHandler); + /** + * Sends PUBACK packet to server + * + * @param publishMessageId identifier of the PUBLISH message to acknowledge + * @param reasonCode reason code + * @param properties MQTT properties + * @return a {@code Future} completed after PUBACK packet sent + */ + @GenIgnore + Future publishAcknowledge(int publishMessageId, MqttPubAckReasonCode reasonCode, MqttProperties properties); + + /** + * Sends PUBREC packet to server + * + * @param publishMessageId identifier of the PUBLISH message to acknowledge + * @param reasonCode reason code + * @param properties MQTT properties + * @return a {@code Future} completed after PUBREC packet sent + */ + @GenIgnore + Future publishReceived(int publishMessageId, MqttPubRecReasonCode reasonCode, MqttProperties properties); + + /** + * Sends PUBREL packet to server + * + * @param publishMessageId identifier of the PUBLISH message to acknowledge + * @param reasonCode reason code + * @param properties MQTT properties + * @return a {@code Future} completed after PUBREL packet sent + */ + @GenIgnore + Future publishRelease(int publishMessageId, MqttPubRelReasonCode reasonCode, MqttProperties properties); + + /** + * Sends PUBCOMP packet to server + * + * @param publishMessageId identifier of the PUBLISH message to acknowledge + * @param reasonCode reason code + * @param properties MQTT properties + * @return a {@code Future} completed after PUBCOMP packet sent + */ + @GenIgnore + Future publishComplete(int publishMessageId, MqttPubCompReasonCode reasonCode, MqttProperties properties); + /** * Sets a handler which will be called when the client receives a PUBACK/PUBREC/PUBCOMP with an unknown * packet ID. @@ -225,6 +357,29 @@ static MqttClient create(Vertx vertx) { */ Future subscribe(Map topics); + /** + * Subscribes to the topics with related QoS levels + * + * @param topics topics and related QoS levels to subscribe to + * @param properties MQTT properties + * @return a {@code Future} completed after SUBSCRIBE packet sent with packetid + */ + @GenIgnore + Future subscribe(Map topics, MqttProperties properties); + + /** + * Subscribes to a list of topics with MQTT 5.0 subscription options (No Local, + * Retain As Published, Retain Handling) and optional properties. + * Each {@link MqttTopicSubscription} carries the topic filter and a + * {@link MqttSubscriptionOption} that encodes QoS plus the v5 options. + * + * @param subscriptions list of topic subscriptions with options + * @param properties MQTT properties (e.g. Subscription Identifier) + * @return a {@code Future} completed after SUBSCRIBE packet sent with packetid + */ + @GenIgnore + Future subscribe(List subscriptions, MqttProperties properties); + /** * Subscribes to the topic and adds a handler which will be called after the request is sent @@ -246,6 +401,15 @@ static MqttClient create(Vertx vertx) { @Fluent MqttClient unsubscribeCompletionHandler(Handler unsubscribeCompletionHandler); + /** + * Sets handler which will be called after UNSUBACK packet receiving + * + * @param unsubscribeCompletionMessageHandler handler to call with the unsubscribe message + * @return current MQTT client instance + */ + @Fluent + MqttClient unsubscribeCompletionMessageHandler(Handler unsubscribeCompletionMessageHandler); + /** * Unsubscribe from receiving messages on given topic * @@ -262,6 +426,16 @@ static MqttClient create(Vertx vertx) { */ Future unsubscribe(List topics); + /** + * Unsubscribe from receiving messages on given list of topic + * + * @param topics list of topics you want to unsubscribe from + * @param properties MQTT properties + * @return a {@code Future} completed after UNSUBSCRIBE packet sent with packetid + */ + @GenIgnore + Future unsubscribe(List topics, MqttProperties properties); + /** * Unsubscribe from receiving messages on given topics * @@ -303,6 +477,20 @@ static MqttClient create(Vertx vertx) { @Fluent MqttClient exceptionHandler(Handler handler); + /** + * Sets a handler that will be called when the server sends a DISCONNECT packet. + *

+ * This fires before {@link #closeHandler(Handler)} and only for server-initiated + * disconnects (not when the client calls {@link #disconnect()}). + * The handler receives the reason code and properties from the server's DISCONNECT packet. + * + * @param handler handler to call with the disconnect message + * @return current MQTT client instance + */ + @Fluent + @GenIgnore(GenIgnore.PERMITTED_TYPE) + MqttClient disconnectMessageHandler(Handler handler); + /** * Set a handler that will be called when the connection with server is closed * diff --git a/src/main/java/io/vertx/mqtt/MqttClientOptions.java b/src/main/java/io/vertx/mqtt/MqttClientOptions.java index bbd66493..f9d285b8 100644 --- a/src/main/java/io/vertx/mqtt/MqttClientOptions.java +++ b/src/main/java/io/vertx/mqtt/MqttClientOptions.java @@ -16,6 +16,7 @@ package io.vertx.mqtt; +import io.netty.handler.codec.mqtt.MqttVersion; import io.vertx.codegen.annotations.DataObject; import io.vertx.codegen.annotations.GenIgnore; import io.vertx.codegen.json.annotations.JsonGen; @@ -23,8 +24,7 @@ import io.vertx.core.impl.Arguments; import io.vertx.core.json.JsonObject; import io.vertx.core.net.*; - -import java.nio.charset.StandardCharsets; +import io.vertx.mqtt.messages.MqttPublishMessage; /** * Represents options used by the MQTT client. @@ -34,28 +34,44 @@ public class MqttClientOptions extends NetClientOptions { public static final int DEFAULT_PORT = 1883; - public static final int DEFAULT_TSL_PORT = 8883; + public static final int DEFAULT_TLS_PORT = 8883; + /** + * @deprecated Use {@link #DEFAULT_TLS_PORT} (this constant has a typo in the name) + */ + @Deprecated + public static final int DEFAULT_TSL_PORT = DEFAULT_TLS_PORT; public static final String DEFAULT_HOST = "localhost"; + /** + * @deprecated Use {@link MqttClientWillOptions#DEFAULT_WILL_QOS} + */ + @Deprecated public static final int DEFAULT_WILL_QOS = 0; public static final int DEFAULT_KEEP_ALIVE_INTERVAL = 30; public static final int DEFAULT_MAX_INFLIGHT_QUEUE = 10; public static final boolean DEFAULT_CLEAN_SESSION = true; + /** + * @deprecated will message is now configured via {@link MqttClientWillOptions}; this constant has no replacement + */ + @Deprecated public static final boolean DEFAULT_WILL_FLAG = false; + /** + * @deprecated Use {@link MqttClientWillOptions#DEFAULT_WILL_RETAIN} + */ + @Deprecated public static final boolean DEFAULT_WILL_RETAIN = false; public static final int DEFAULT_MAX_MESSAGE_SIZE = -1; public static final int DEFAULT_ACK_TIMEOUT = -1; public static final boolean DEFAULT_AUTO_ACK = true; public static final int DEFAULT_RECV_BYTE_BUF_ALLOCATOR_SIZE = -1; + public static final int DEFAULT_VERSION = 4; + public static final Integer DEFAULT_TOPIC_ALIAS_MAXIMUM = 255; + public static final boolean DEFAULT_AUTO_SERVER_REDIRECT = true; private String clientId; private String username; private String password; - private String willTopic; - private Buffer willMessageBytes; + private MqttClientWillOptions willOptions = new MqttClientWillOptions(); private boolean cleanSession = DEFAULT_CLEAN_SESSION; - private boolean willFlag = DEFAULT_WILL_FLAG; - private int willQoS = DEFAULT_WILL_QOS; - private boolean willRetain = DEFAULT_WILL_RETAIN; private int keepAliveInterval = DEFAULT_KEEP_ALIVE_INTERVAL; private boolean isAutoKeepAlive = true; private boolean isAutoGeneratedClientId = true; @@ -64,6 +80,16 @@ public class MqttClientOptions extends NetClientOptions { private int ackTimeout = DEFAULT_ACK_TIMEOUT; private boolean autoAck = DEFAULT_AUTO_ACK; private int recvByteBufAllocatorSize = DEFAULT_RECV_BYTE_BUF_ALLOCATOR_SIZE; + private int version = DEFAULT_VERSION; + private Long sessionExpireInterval = null; + private Integer receiveMaximum = null; + private Long maximumPacketSize = null; + private Integer topicAliasMaximum = DEFAULT_TOPIC_ALIAS_MAXIMUM; + private Boolean requestResponseInformation = null; + private Boolean requestProblemInformation = null; + private String authenticationMethod = null; + private Buffer authenticationData = null; + private boolean autoServerRedirect = DEFAULT_AUTO_SERVER_REDIRECT; /** * Default constructor @@ -75,9 +101,7 @@ public MqttClientOptions() { private void init() { this.cleanSession = DEFAULT_CLEAN_SESSION; - this.willFlag = DEFAULT_WILL_FLAG; - this.willQoS = DEFAULT_WILL_QOS; - this.willRetain = DEFAULT_WILL_RETAIN; + this.willOptions = new MqttClientWillOptions(); this.keepAliveInterval = DEFAULT_KEEP_ALIVE_INTERVAL; this.isAutoKeepAlive = true; this.isAutoGeneratedClientId = true; @@ -86,6 +110,8 @@ private void init() { this.ackTimeout = DEFAULT_ACK_TIMEOUT; this.autoAck = DEFAULT_AUTO_ACK; this.recvByteBufAllocatorSize = DEFAULT_RECV_BYTE_BUF_ALLOCATOR_SIZE; + this.version = DEFAULT_VERSION; + this.topicAliasMaximum = DEFAULT_TOPIC_ALIAS_MAXIMUM; } /** @@ -97,8 +123,9 @@ public MqttClientOptions(JsonObject json) { super(json); init(); MqttClientOptionsConverter.fromJson(json, this); - if (!json.containsKey("willMessageBytes") && json.containsKey("willMessage")) { - willMessageBytes = Buffer.buffer(json.getString("willMessage")); + // Extra backward compat: old "willMessage" string key (pre-willOptions era) + if (!json.containsKey("willOptions") && !json.containsKey("willMessageBytes") && json.containsKey("willMessage")) { + this.willOptions.setMessageBytes(Buffer.buffer(json.getString("willMessage"))); } } @@ -112,12 +139,8 @@ public MqttClientOptions(MqttClientOptions other) { this.clientId = other.clientId; this.username = other.username; this.password = other.password; - this.willTopic = other.willTopic; - this.willMessageBytes = other.willMessageBytes; + this.willOptions = new MqttClientWillOptions(other.willOptions); this.cleanSession = other.cleanSession; - this.willFlag = other.willFlag; - this.willQoS = other.willQoS; - this.willRetain = other.willRetain; this.keepAliveInterval = other.keepAliveInterval; this.isAutoKeepAlive = other.isAutoKeepAlive; this.isAutoGeneratedClientId = other.isAutoGeneratedClientId; @@ -126,6 +149,16 @@ public MqttClientOptions(MqttClientOptions other) { this.ackTimeout = other.ackTimeout; this.autoAck = other.autoAck; this.recvByteBufAllocatorSize = other.recvByteBufAllocatorSize; + this.version = other.version; + this.sessionExpireInterval = other.sessionExpireInterval; + this.receiveMaximum = other.receiveMaximum; + this.maximumPacketSize = other.maximumPacketSize; + this.topicAliasMaximum = other.topicAliasMaximum; + this.requestResponseInformation = other.requestResponseInformation; + this.requestProblemInformation = other.requestProblemInformation; + this.authenticationMethod = other.authenticationMethod; + this.authenticationData = other.authenticationData; + this.autoServerRedirect = other.autoServerRedirect; } /** @@ -150,24 +183,41 @@ public boolean isCleanSession() { } /** - * @return if will information are provided on connection + * @return the will message options object (includes MQTT 5.0 properties) + */ + public MqttClientWillOptions getWillOptions() { + return willOptions; + } + + /** + * Set the will message options. + * + * @param willOptions will options + * @return current options instance */ - public boolean isWillFlag() { - return willFlag; + public MqttClientOptions setWillOptions(MqttClientWillOptions willOptions) { + this.willOptions = willOptions != null ? willOptions : new MqttClientWillOptions(); + return this; } /** * @return if the will messages must be retained + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#isRetain()} */ + @GenIgnore + @Deprecated public boolean isWillRetain() { - return willRetain; + return willOptions.isRetain(); } /** * @return the QoS level for the will message + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#getQos()} */ + @GenIgnore + @Deprecated public int getWillQoS() { - return willQoS; + return willOptions.getQos(); } /** @@ -200,9 +250,12 @@ public String getClientId() { /** * @return topic on which the will message will be published + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#getTopic()} */ + @GenIgnore + @Deprecated public String getWillTopic() { - return willTopic; + return willOptions.getTopic(); } /** @@ -211,14 +264,18 @@ public String getWillTopic() { @Deprecated @GenIgnore public String getWillMessage() { - return willMessageBytes.toString(StandardCharsets.UTF_8); + Buffer mb = willOptions.getMessageBytes(); + return mb != null ? mb.toString() : null; } /** * @return will message bytes content + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#getMessageBytes()} */ + @GenIgnore + @Deprecated public Buffer getWillMessageBytes() { - return willMessageBytes; + return willOptions.getMessageBytes(); } /** @@ -235,7 +292,7 @@ public MqttClientOptions setClientId(String clientId) { /** * Set the username * - * @param username username + * @param username username * @return current options instance */ public MqttClientOptions setUsername(String username) { @@ -246,7 +303,7 @@ public MqttClientOptions setUsername(String username) { /** * Set the password * - * @param password password + * @param password password * @return current options instance */ public MqttClientOptions setPassword(String password) { @@ -255,26 +312,30 @@ public MqttClientOptions setPassword(String password) { } /** - * Set the topic on which the will message will be published + * Set the topic on which the will message will be published. * * @param willTopic topic on which the will message will be published * @return current options instance + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#setTopic(String)} */ + @GenIgnore + @Deprecated public MqttClientOptions setWillTopic(String willTopic) { - this.willTopic = willTopic; + this.willOptions.setTopic(willTopic); return this; } /** - * Set the content of the will message + * Set the content of the will message. * * @param willMessage content of the will message * @return current options instance + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#setMessageBytes(Buffer)} */ @Deprecated @GenIgnore public MqttClientOptions setWillMessage(String willMessage) { - this.willMessageBytes = Buffer.buffer(willMessage.getBytes(StandardCharsets.UTF_8)); + this.willOptions.setMessageBytes(Buffer.buffer(willMessage)); return this; } @@ -284,8 +345,10 @@ public MqttClientOptions setWillMessage(String willMessage) { * @param willMessage content of the will message * @return current options instance */ + @GenIgnore + @Deprecated public MqttClientOptions setWillMessageBytes(Buffer willMessage) { - this.willMessageBytes = willMessage; + this.willOptions.setMessageBytes(willMessage); return this; } @@ -301,35 +364,30 @@ public MqttClientOptions setCleanSession(boolean cleanSession) { } /** - * Set if will information are provided on connection - * - * @param willFlag if will information are provided on connection - * @return current options instance - */ - public MqttClientOptions setWillFlag(boolean willFlag) { - this.willFlag = willFlag; - return this; - } - - /** - * Set the QoS level for the will message + * Set the QoS level for the will message. * * @param willQoS QoS level for the will message * @return current options instance + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#setQos(int)} */ + @GenIgnore + @Deprecated public MqttClientOptions setWillQoS(int willQoS) { - this.willQoS = willQoS; + this.willOptions.setQos(willQoS); return this; } /** - * Set if the will message must be retained + * Set if the will message must be retained. * - * @param willRetain if thw will message must be retained + * @param willRetain if the will message must be retained * @return current options instance + * @deprecated Use {@link #getWillOptions()} and {@link MqttClientWillOptions#setRetain(boolean)} */ + @GenIgnore + @Deprecated public MqttClientOptions setWillRetain(boolean willRetain) { - this.willRetain = willRetain; + this.willOptions.setRetain(willRetain); return this; } @@ -387,6 +445,7 @@ public int getMaxInflightQueue() { /** * Set max count of unacknowledged messages + * * @param maxInflightQueue max count of unacknowledged messages * @return current options instance */ @@ -400,7 +459,7 @@ public MqttClientOptions setMaxInflightQueue(int maxInflightQueue) { * (default is true) * * @param isAutoKeepAlive ping request handled automatically - * @return current options instance + * @return current options instance */ public MqttClientOptions setAutoKeepAlive(boolean isAutoKeepAlive) { this.isAutoKeepAlive = isAutoKeepAlive; @@ -412,7 +471,7 @@ public MqttClientOptions setAutoKeepAlive(boolean isAutoKeepAlive) { * (default is true) * * @param isAutoGeneratedClientId clientId generated automatically - * @return current options instance + * @return current options instance */ public MqttClientOptions setAutoGeneratedClientId(boolean isAutoGeneratedClientId) { this.isAutoGeneratedClientId = isAutoGeneratedClientId; @@ -430,6 +489,7 @@ public boolean isAutoAck() { * Set to false to let the application code to ack the message via {@link MqttPublishMessage#ack()}. * If true, the ack (PUBACK/PUBCOMP) will be sent by vertx-mqtt before {@link MqttClient#publishHandler()} execution. * (default is true) + * * @param autoAck */ public void setAutoAck(boolean autoAck) { @@ -489,8 +549,8 @@ public MqttClientOptions setReceiveBufferSize(int receiveBufferSize) { /** * Set max MQTT message size * - * @param maxMessageSize max MQTT message size - * @return MQTT client options instance + * @param maxMessageSize max MQTT message size + * @return MQTT client options instance */ public MqttClientOptions setMaxMessageSize(int maxMessageSize) { Arguments.require(maxMessageSize > 0 || maxMessageSize == DEFAULT_MAX_MESSAGE_SIZE, "maxMessageSize must be > 0"); @@ -521,6 +581,118 @@ public int getIdleTimeout() { return 0; } + public int getVersion() { + return version; + } + + public void setVersion(int version) { + if (version != MqttVersion.MQTT_3_1_1.protocolLevel() && version != MqttVersion.MQTT_5.protocolLevel()) + throw new IllegalArgumentException("Invalid MQTT Version (4 or 5 is accepted)"); + this.version = version; + } + + public Long getSessionExpireInterval() { + return sessionExpireInterval; + } + + public void setSessionExpireInterval(Long sessionExpireInterval) { + if (sessionExpireInterval != null && + (sessionExpireInterval < 0L || sessionExpireInterval > 0xFFFFFFFFL)) { + throw new IllegalArgumentException("Invalid Session Expire Interval"); + } + this.sessionExpireInterval = sessionExpireInterval; + } + + public Integer getReceiveMaximum() { + return receiveMaximum; + } + + public void setReceiveMaximum(Integer receiveMaximum) { + if (receiveMaximum != null && + (receiveMaximum < 0L || receiveMaximum > 0xFFFFL)) { + throw new IllegalArgumentException("Invalid Receive Maximum"); + } + this.receiveMaximum = receiveMaximum; + } + + public Long getMaximumPacketSize() { + return maximumPacketSize; + } + + public void setMaximumPacketSize(Long maximumPacketSize) { + if (sessionExpireInterval != null && + (sessionExpireInterval < 0L || sessionExpireInterval > 0xFFFFFFFFL)) { + throw new IllegalArgumentException("Invalid Maximum Packet Size"); + } + this.maximumPacketSize = maximumPacketSize; + } + + public Integer getTopicAliasMaximum() { + return topicAliasMaximum; + } + + public void setTopicAliasMaximum(Integer topicAliasMaximum) { + if (receiveMaximum != null && + (receiveMaximum < 0L || receiveMaximum > 0xFFFFL)) { + throw new IllegalArgumentException("Invalid Topic Alias Maximum"); + } + this.topicAliasMaximum = topicAliasMaximum; + } + + public Boolean getRequestResponseInformation() { + return requestResponseInformation; + } + + public void setRequestResponseInformation(Boolean requestResponseInformation) { + this.requestResponseInformation = requestResponseInformation; + } + + public Boolean getRequestProblemInformation() { + return requestProblemInformation; + } + + public void setRequestProblemInformation(Boolean requestProblemInformation) { + this.requestProblemInformation = requestProblemInformation; + } + + public String getAuthenticationMethod() { + return authenticationMethod; + } + + public void setAuthenticationMethod(String authenticationMethod) { + this.authenticationMethod = authenticationMethod; + } + + public Buffer getAuthenticationData() { + return authenticationData; + } + + public void setAuthenticationData(Buffer authenticationData) { + this.authenticationData = authenticationData; + } + + /** + * @return whether the client will automatically reconnect to the server indicated + * in the SERVER_REFERENCE property of a CONNACK or DISCONNECT packet (MQTT 5.0) + */ + public boolean isAutoServerRedirect() { + return autoServerRedirect; + } + + /** + * When {@code true} (default) and the broker replies with a CONNACK or DISCONNECT + * that includes a {@code SERVER_REFERENCE} property (MQTT 5.0 §3.2.2.3.18 / §3.14.2.3.4), + * the client will transparently reconnect to a server picked at random from the + * comma-separated list in that property instead of failing. + * + * @param autoServerRedirect {@code true} to enable automatic server redirection + * @return current options instance + */ + public MqttClientOptions setAutoServerRedirect(boolean autoServerRedirect) { + this.autoServerRedirect = autoServerRedirect; + return this; + } + @Override public MqttClientOptions setSsl(boolean ssl) { super.setSsl(ssl); @@ -547,8 +719,8 @@ public MqttClientOptions setTrustAll(boolean trustAll) { @Override public MqttClientOptions setKeyCertOptions(KeyCertOptions options) { - super.setKeyCertOptions(options); - return this; + super.setKeyCertOptions(options); + return this; } @Override @@ -571,8 +743,8 @@ public MqttClientOptions setPemKeyCertOptions(PemKeyCertOptions options) { @Override public MqttClientOptions setTrustOptions(TrustOptions options) { - super.setTrustOptions(options); - return this; + super.setTrustOptions(options); + return this; } @Override @@ -589,26 +761,26 @@ public MqttClientOptions setPfxTrustOptions(PfxOptions options) { @Override public MqttClientOptions addEnabledCipherSuite(String suite) { - super.addEnabledCipherSuite(suite); - return this; + super.addEnabledCipherSuite(suite); + return this; } @Override public MqttClientOptions addEnabledSecureTransportProtocol(String protocol) { - super.addEnabledSecureTransportProtocol(protocol); - return this; + super.addEnabledSecureTransportProtocol(protocol); + return this; } @Override public MqttClientOptions addCrlPath(String crlPath) throws NullPointerException { - super.addCrlPath(crlPath); - return this; + super.addCrlPath(crlPath); + return this; } @Override public MqttClientOptions addCrlValue(Buffer crlValue) throws NullPointerException { - super.addCrlValue(crlValue); - return this; + super.addCrlValue(crlValue); + return this; } @Override @@ -624,16 +796,22 @@ public String toString() { "clientId='" + clientId + '\'' + ", username='" + username + '\'' + ", password='" + password + '\'' + - ", willTopic='" + willTopic + '\'' + - ", willMessageBytes='" + willMessageBytes + '\'' + + ", will=" + willOptions + ", cleanSession=" + cleanSession + - ", willFlag=" + willFlag + - ", willQoS=" + willQoS + - ", willRetain=" + willRetain + ", keepAliveTimeSeconds=" + keepAliveInterval + ", isAutoKeepAlive=" + isAutoKeepAlive + ", isAutoGeneratedClientId=" + isAutoGeneratedClientId + ", isAutoAck=" + autoAck + + ", version=" + version + + ", sessionExpireInterval=" + sessionExpireInterval + + ", receiveMaximum=" + receiveMaximum + + ", maximumPacketSize=" + maximumPacketSize + + ", topicAliasMaximum=" + topicAliasMaximum + + ", requestResponseInformation=" + requestResponseInformation + + ", requestProblemInformation=" + requestProblemInformation + + ", authenticationMethod=" + authenticationMethod + + ", authenticationData=" + (authenticationData != null ? "[" + authenticationData.length() + " bytes]" : "null") + + ", autoServerRedirect=" + autoServerRedirect + '}'; } } diff --git a/src/main/java/io/vertx/mqtt/MqttClientWillOptions.java b/src/main/java/io/vertx/mqtt/MqttClientWillOptions.java new file mode 100644 index 00000000..070c4db7 --- /dev/null +++ b/src/main/java/io/vertx/mqtt/MqttClientWillOptions.java @@ -0,0 +1,327 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt; + +import io.vertx.codegen.annotations.DataObject; +import io.vertx.codegen.json.annotations.JsonGen; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Options for the MQTT Last Will and Testament (LWT) message. + *

+ * Supports both MQTT 3.1.1 (topic, payload, QoS, retain) and MQTT 5.0 + * additional properties (Will Delay Interval, Payload Format Indicator, + * Content Type, Response Topic, Correlation Data, User Properties). + */ +@DataObject +@JsonGen(publicConverter = false) +public class MqttClientWillOptions { + + public static final int DEFAULT_WILL_QOS = 0; + public static final boolean DEFAULT_WILL_RETAIN = false; + + // MQTT 3.1.1 fields + private String topic; + private Buffer messageBytes; + private int qos = DEFAULT_WILL_QOS; + private boolean retain = DEFAULT_WILL_RETAIN; + + // MQTT 5.0 only fields + /** Will Delay Interval in seconds (0..4294967295). Null means not set. */ + private Long willDelayInterval; + /** + * Payload Format Indicator: 0 = unspecified bytes, 1 = UTF-8 encoded. + * Null means not set. + */ + private Integer payloadFormatIndicator; + /** MIME type describing the content of the will payload. */ + private String contentType; + /** Topic name for a request message. */ + private String responseTopic; + /** Correlation data used to correlate a request message. */ + private Buffer correlationData; + /** + * User properties as key-value pairs. (MQTT 5.0 only) + */ + private Map userProperties; + + /** Default constructor. */ + public MqttClientWillOptions() { + } + + /** Copy constructor. */ + public MqttClientWillOptions(MqttClientWillOptions other) { + this.topic = other.topic; + this.messageBytes = other.messageBytes; + this.qos = other.qos; + this.retain = other.retain; + this.willDelayInterval = other.willDelayInterval; + this.payloadFormatIndicator = other.payloadFormatIndicator; + this.contentType = other.contentType; + this.responseTopic = other.responseTopic; + this.correlationData = other.correlationData; + this.userProperties = other.userProperties != null ? new LinkedHashMap<>(other.userProperties) : null; + } + + /** Create instance from JSON (delegates to generated converter). */ + public MqttClientWillOptions(JsonObject json) { + MqttClientWillOptionsConverter.fromJson(json, this); + } + + /** Convert instance to JSON (delegates to generated converter). */ + public JsonObject toJson() { + JsonObject json = new JsonObject(); + MqttClientWillOptionsConverter.toJson(this, json); + return json; + } + + // ------------------------------------------------------------------------- + // MQTT 3.1.1 getters / setters + // ------------------------------------------------------------------------- + + /** + * @return topic on which the will message will be published + */ + public String getTopic() { + return topic; + } + + /** + * Set the topic on which the will message will be published. + * + * @param topic will topic + * @return this options instance + */ + public MqttClientWillOptions setTopic(String topic) { + this.topic = topic; + return this; + } + + /** + * @return will message payload bytes + */ + public Buffer getMessageBytes() { + return messageBytes; + } + + /** + * Set the will message payload. + * + * @param messageBytes will payload + * @return this options instance + */ + public MqttClientWillOptions setMessageBytes(Buffer messageBytes) { + this.messageBytes = messageBytes; + return this; + } + + /** + * @return QoS level for the will message (0, 1 or 2) + */ + public int getQos() { + return qos; + } + + /** + * Set the QoS level for the will message. + * + * @param qos QoS level (0, 1 or 2) + * @return this options instance + */ + public MqttClientWillOptions setQos(int qos) { + this.qos = qos; + return this; + } + + /** + * @return whether the will message must be retained + */ + public boolean isRetain() { + return retain; + } + + /** + * Set whether the will message must be retained. + * + * @param retain true to retain the will message + * @return this options instance + */ + public MqttClientWillOptions setRetain(boolean retain) { + this.retain = retain; + return this; + } + + // ------------------------------------------------------------------------- + // MQTT 5.0 getters / setters + // ------------------------------------------------------------------------- + + /** + * @return Will Delay Interval in seconds, or null if not set (MQTT 5.0) + */ + public Long getWillDelayInterval() { + return willDelayInterval; + } + + /** + * Set the Will Delay Interval. + *

+ * The broker delays publishing the will message until this interval (in seconds) + * elapses after the network connection is closed, or until the session ends. + * If null, the broker publishes the will message immediately. (MQTT 5.0 only) + * + * @param willDelayInterval delay in seconds (0..4294967295), or null to unset + * @return this options instance + */ + public MqttClientWillOptions setWillDelayInterval(Long willDelayInterval) { + if (willDelayInterval != null && (willDelayInterval < 0L || willDelayInterval > 0xFFFFFFFFL)) { + throw new IllegalArgumentException("Invalid Will Delay Interval: " + willDelayInterval); + } + this.willDelayInterval = willDelayInterval; + return this; + } + + /** + * @return Payload Format Indicator (0=bytes, 1=UTF-8), or null if not set (MQTT 5.0) + */ + public Integer getPayloadFormatIndicator() { + return payloadFormatIndicator; + } + + /** + * Set the Payload Format Indicator. + *

+ * 0 means the will payload is unspecified bytes; 1 means it is UTF-8 encoded. + * (MQTT 5.0 only) + * + * @param payloadFormatIndicator 0 or 1, or null to unset + * @return this options instance + */ + public MqttClientWillOptions setPayloadFormatIndicator(Integer payloadFormatIndicator) { + if (payloadFormatIndicator != null && (payloadFormatIndicator < 0 || payloadFormatIndicator > 1)) { + throw new IllegalArgumentException("Payload Format Indicator must be 0 or 1"); + } + this.payloadFormatIndicator = payloadFormatIndicator; + return this; + } + + /** + * @return Content Type (MIME type) of the will payload, or null if not set (MQTT 5.0) + */ + public String getContentType() { + return contentType; + } + + /** + * Set the Content Type (MIME type) that describes the will payload. (MQTT 5.0 only) + * + * @param contentType MIME type string, or null to unset + * @return this options instance + */ + public MqttClientWillOptions setContentType(String contentType) { + this.contentType = contentType; + return this; + } + + /** + * @return Response Topic for request/response pattern, or null if not set (MQTT 5.0) + */ + public String getResponseTopic() { + return responseTopic; + } + + /** + * Set the Response Topic used in a request/response pattern. (MQTT 5.0 only) + * + * @param responseTopic topic name, or null to unset + * @return this options instance + */ + public MqttClientWillOptions setResponseTopic(String responseTopic) { + this.responseTopic = responseTopic; + return this; + } + + /** + * @return Correlation Data used to correlate a request, or null if not set (MQTT 5.0) + */ + public Buffer getCorrelationData() { + return correlationData; + } + + /** + * Set the Correlation Data used to correlate a request/response. (MQTT 5.0 only) + * + * @param correlationData binary data, or null to unset + * @return this options instance + */ + public MqttClientWillOptions setCorrelationData(Buffer correlationData) { + this.correlationData = correlationData; + return this; + } + + /** + * @return User Properties as key-value pairs, or null if not set (MQTT 5.0) + */ + public Map getUserProperties() { + return userProperties; + } + + /** + * Set the User Properties. (MQTT 5.0 only) + * + * @param userProperties map of key-value pairs, or null to unset + * @return this options instance + */ + public MqttClientWillOptions setUserProperties(Map userProperties) { + this.userProperties = userProperties; + return this; + } + + /** + * Add a single User Property. (MQTT 5.0 only) + * + * @param key property key + * @param value property value + * @return this options instance + */ + public MqttClientWillOptions addUserProperty(String key, String value) { + if (this.userProperties == null) { + this.userProperties = new LinkedHashMap<>(); + } + this.userProperties.put(key, value); + return this; + } + + @Override + public String toString() { + return "MqttClientWillOptions{" + + ", topic='" + topic + '\'' + + ", messageBytes=" + messageBytes + + ", qos=" + qos + + ", retain=" + retain + + ", willDelayInterval=" + willDelayInterval + + ", payloadFormatIndicator=" + payloadFormatIndicator + + ", contentType='" + contentType + '\'' + + ", responseTopic='" + responseTopic + '\'' + + ", correlationData=" + correlationData + + ", userProperties=" + userProperties + + '}'; + } +} diff --git a/src/main/java/io/vertx/mqtt/MqttException.java b/src/main/java/io/vertx/mqtt/MqttException.java index 4f8b5100..cefc4a55 100644 --- a/src/main/java/io/vertx/mqtt/MqttException.java +++ b/src/main/java/io/vertx/mqtt/MqttException.java @@ -20,10 +20,17 @@ * Exception raised with a specific reason code */ public class MqttException extends Throwable { - + + private static final long serialVersionUID = -6330343007516479948L; + public final static int MQTT_INVALID_TOPIC_NAME = 0; public final static int MQTT_INVALID_TOPIC_FILTER = 1; public final static int MQTT_INFLIGHT_QUEUE_FULL = 2; + public final static int MQTT_QOS_UNSUPPORTED = 3; + public final static int MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED = 4; + public final static int MQTT_WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED = 5; + public final static int MQTT_SHARED_SUBSCRIPTIONS_NOT_SUPPORTED = 6; + public final static int MQTT_PACKET_TOO_LARGE = 7; private final int code; diff --git a/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java b/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java index e8653c69..608e3377 100644 --- a/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java +++ b/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java @@ -24,6 +24,10 @@ import io.netty.channel.FixedRecvByteBufAllocator; import io.netty.handler.codec.DecoderResult; import io.netty.handler.codec.mqtt.*; +import io.netty.handler.codec.mqtt.MqttProperties.BinaryProperty; +import io.netty.handler.codec.mqtt.MqttProperties.IntegerProperty; +import io.netty.handler.codec.mqtt.MqttProperties.StringPair; +import io.netty.handler.codec.mqtt.MqttProperties.UserProperties; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; import io.netty.handler.timeout.IdleStateHandler; @@ -47,10 +51,24 @@ import io.vertx.mqtt.messages.MqttMessage; import io.vertx.mqtt.messages.MqttPublishMessage; import io.vertx.mqtt.messages.MqttSubAckMessage; +import io.vertx.mqtt.messages.MqttAuthenticationExchangeMessage; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; +import io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubAckReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubRecReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubRelReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubCompReasonCode; +import io.vertx.mqtt.messages.MqttDisconnectMessage; +import io.vertx.mqtt.messages.MqttPubAckMessage; +import io.vertx.mqtt.messages.MqttPubRecMessage; +import io.vertx.mqtt.messages.MqttPubCompMessage; +import io.vertx.mqtt.messages.MqttUnsubAckMessage; import io.vertx.mqtt.messages.impl.MqttPublishMessageImpl; import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.concurrent.ThreadLocalRandom; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -73,7 +91,6 @@ private enum Status { CLOSED, CONNECTING, CONNECTED, CLOSING } private static final int MAX_TOPIC_LEN = 65535; private static final int MIN_TOPIC_LEN = 1; private static final String PROTOCOL_NAME = "MQTT"; - private static final int PROTOCOL_VERSION = 4; private final VertxInternal vertx; private final MqttClientOptions options; @@ -89,10 +106,19 @@ private enum Status { CLOSED, CONNECTING, CONNECTED, CLOSING } private Handler publishCompletionPhantomHandler; // handler to call when a unsubscribe request is completed private Handler unsubscribeCompletionHandler; + // handler to call when a UNSUBACK is received + private Handler unsubscribeCompletionMessageHandler; // handler to call when a publish message comes in private Handler publishHandler; // handler to call when a subscribe request is completed private Handler subscribeCompletionHandler; + // handler to call when an auth message comes in + private Handler authenticationExchangeHandler; + // MQTT 5.0 typed handlers for incoming PUBACK/PUBREC/PUBCOMP with reason codes + private Handler publishAckMessageHandler; + private Handler publishRecMessageHandler; + private Handler publishCompMessageHandler; + // handler to call when a connection request is completed private Promise connectPromise; // handler to call when a connection disconnects @@ -103,6 +129,10 @@ private enum Status { CLOSED, CONNECTING, CONNECTED, CLOSING } private Handler exceptionHandler; //handler to call when the remote MQTT server closes the connection private Handler closeHandler; + // handler to call when a server-initiated DISCONNECT is received (fires before closeHandler) + private Handler disconnectMessageHandler; + // pending server-initiated DISCONNECT message (built in handleMessage, fired in handleClosed) + private MqttDisconnectMessage pendingDisconnectMessage = null; // storage of PUBLISH QoS=1 messages which was not responded with PUBACK private HashMap qos1outbound = new HashMap<>(); @@ -114,6 +144,25 @@ private enum Status { CLOSED, CONNECTING, CONNECTED, CLOSING } // storage of PUBLISH messages which was responded with PUBREC private HashMap qos2inbound = new HashMap<>(); + // MQTT5 Topic alias: topic → alias number (client-to-server direction, outgoing PUBLISH) + private HashMap topicAlias = new HashMap<>(); + // Maximum number of topic aliases the server accepts (from CONNACK TOPIC_ALIAS_MAXIMUM, 0 = disabled) + private int serverTopicAliasMaximum = 0; + // MQTT5 Topic alias: alias → topic (server-to-client direction, incoming PUBLISH) + private HashMap serverTopicAlias = new HashMap<>(); + // Whether the server supports Subscription Identifiers (from CONNACK, default true per spec §3.2.2.3.12) + private boolean serverSubscriptionIdentifierAvailable = true; + // Whether the server supports Wildcard Subscriptions (from CONNACK, default true per spec §3.2.2.3.11) + private boolean serverWildcardSubscriptionAvailable = true; + // Whether the server supports Shared Subscriptions (from CONNACK, default true per spec §3.2.2.3.14) + private boolean serverSharedSubscriptionAvailable = true; + // Maximum concurrent QoS1/2 in-flight messages the server accepts (from CONNACK RECEIVE_MAXIMUM) + private int serverReceiveMaximum = Integer.MAX_VALUE; + // Maximum QoS the server accepts (from CONNACK MAXIMUM_QOS: 0, 1 or 2; default 2) + private int serverMaxQos = 2; + // Maximum packet size the server accepts (from CONNACK MAXIMUM_PACKET_SIZE; default = unlimited) + private long serverMaximumPacketSize = Long.MAX_VALUE; + // counter for the message identifier private int messageIdCounter; @@ -127,6 +176,9 @@ private enum Status { CLOSED, CONNECTING, CONNECTED, CLOSING } private NetClient client; private Status status = Status.CLOSED; + // SERVER_REFERENCE target set on receipt of a server-initiated DISCONNECT (redirect pending) + private String pendingRedirect = null; + /** * Constructor * @@ -147,8 +199,7 @@ int getInFlightMessagesCount() { @Override public Future connect(int port, String host) { - - return this.doConnect(port, host, null); + return this.connect(port, host, null, (Map) null); } /** @@ -166,8 +217,7 @@ public MqttClient connect(int port, String host, Handler connect(int port, String host, String serverName) { - - return this.doConnect(port, host, serverName); + return this.connect(port, host, serverName, (Map) null); } /** @@ -176,15 +226,20 @@ public Future connect(int port, String host, String serverNa @Override public MqttClient connect(int port, String host, String serverName, Handler> connectHandler) { - Future fut = this.doConnect(port, host, serverName); + Future fut = this.connect(port, host, serverName); if (connectHandler != null) { fut.onComplete(connectHandler); } return this; } - private Future doConnect(int port, String host, String serverName) { + @Override + public Future connect(int port, String host, String serverName, Map userProperties) { + if (this.options.getVersion() != 5 && userProperties != null) { + throw new IllegalArgumentException("userProperties is available only with MQTTv5"); + } + ContextInternal ctx = vertx.getOrCreateContext(); NetClient client = new NetClientBuilder(vertx, options).closeFuture(new CloseFuture()).build(); PromiseInternal connectPromise = ctx.promise(); @@ -277,22 +332,74 @@ private Future doConnect(int port, String host, String serve false, 0); + MqttProperties props = MqttProperties.NO_PROPERTIES; + + if (options.getVersion() == 5) { + props = new MqttProperties(); + if (options.getSessionExpireInterval() != null) + props.add(new IntegerProperty(MqttProperties.MqttPropertyType.SESSION_EXPIRY_INTERVAL.value(), (int) options.getSessionExpireInterval().longValue())); + if (options.getReceiveMaximum() != null) + props.add(new IntegerProperty(MqttProperties.MqttPropertyType.RECEIVE_MAXIMUM.value(), options.getReceiveMaximum())); + if (options.getMaximumPacketSize() != null) + props.add(new IntegerProperty(MqttProperties.MqttPropertyType.MAXIMUM_PACKET_SIZE.value(), (int) options.getMaximumPacketSize().longValue())); + if (options.getTopicAliasMaximum() != null) + props.add(new IntegerProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS_MAXIMUM.value(), options.getTopicAliasMaximum())); + if (options.getRequestResponseInformation() != null) + props.add(new IntegerProperty(MqttProperties.MqttPropertyType.REQUEST_RESPONSE_INFORMATION.value(), options.getRequestResponseInformation() ? 1 : 0 )); + if (options.getRequestProblemInformation() != null) + props.add(new IntegerProperty(MqttProperties.MqttPropertyType.REQUEST_PROBLEM_INFORMATION.value(), options.getRequestProblemInformation() ? 1 : 0 )); + if (options.getAuthenticationMethod() != null) + props.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value(), options.getAuthenticationMethod())); + if (options.getAuthenticationData() != null) + props.add(new BinaryProperty(MqttProperties.MqttPropertyType.AUTHENTICATION_DATA.value(), options.getAuthenticationData().getBytes())); + if (userProperties != null && !userProperties.isEmpty()) { + Collection values = userProperties.entrySet().stream().map(e -> new StringPair(e.getKey(), e.getValue())).collect(Collectors.toList()); + props.add(new UserProperties(values)); + } + } + + io.vertx.mqtt.MqttClientWillOptions willOpts = options.getWillOptions(); + + boolean willFlag = willOpts.getTopic() != null && willOpts.getMessageBytes() != null; + MqttConnectVariableHeader variableHeader = new MqttConnectVariableHeader( PROTOCOL_NAME, - PROTOCOL_VERSION, + options.getVersion(), options.hasUsername(), options.hasPassword(), - options.isWillRetain(), - options.getWillQoS(), - options.isWillFlag(), + willOpts.isRetain(), + willOpts.getQos(), + willFlag, options.isCleanSession(), - options.getKeepAliveInterval() - ); + options.getKeepAliveInterval(), + props); + + MqttProperties willProperties = MqttProperties.NO_PROPERTIES; + if (options.getVersion() == 5 && willFlag) { + willProperties = new MqttProperties(); + if (willOpts.getWillDelayInterval() != null) + willProperties.add(new IntegerProperty(MqttProperties.MqttPropertyType.WILL_DELAY_INTERVAL.value(), (int) willOpts.getWillDelayInterval().longValue())); + if (willOpts.getPayloadFormatIndicator() != null) + willProperties.add(new IntegerProperty(MqttProperties.MqttPropertyType.PAYLOAD_FORMAT_INDICATOR.value(), willOpts.getPayloadFormatIndicator())); + if (willOpts.getContentType() != null) + willProperties.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.CONTENT_TYPE.value(), willOpts.getContentType())); + if (willOpts.getResponseTopic() != null) + willProperties.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value(), willOpts.getResponseTopic())); + if (willOpts.getCorrelationData() != null) + willProperties.add(new BinaryProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value(), willOpts.getCorrelationData().getBytes())); + if (willOpts.getUserProperties() != null && !willOpts.getUserProperties().isEmpty()) { + Collection pairs = willOpts.getUserProperties().entrySet().stream() + .map(e -> new StringPair(e.getKey(), e.getValue())) + .collect(Collectors.toList()); + willProperties.add(new UserProperties(pairs)); + } + } MqttConnectPayload payload = new MqttConnectPayload( options.getClientId() == null ? "" : options.getClientId(), - options.getWillTopic(), - options.getWillMessageBytes() != null ? options.getWillMessageBytes().getBytes() : null, + willProperties, + willOpts.getTopic(), + willOpts.getMessageBytes() != null ? willOpts.getMessageBytes().getBytes() : null, options.hasUsername() ? options.getUsername() : null, options.hasPassword() ? options.getPassword().getBytes() : null ); @@ -300,6 +407,7 @@ private Future doConnect(int port, String host, String serve io.netty.handler.codec.mqtt.MqttMessage connect = MqttMessageFactory.newMessage(fixedHeader, variableHeader, payload); this.write(connect); + } }); @@ -313,6 +421,11 @@ private Future doConnect(int port, String host, String serve */ @Override public Future disconnect() { + return disconnect(null, MqttProperties.NO_PROPERTIES); + } + + @Override + public Future disconnect(MqttDisconnectReasonCode code, MqttProperties properties) { NetSocketInternal connection; Status status; @@ -348,7 +461,14 @@ public Future disconnect() { false, 0 ); - io.netty.handler.codec.mqtt.MqttMessage disconnect = MqttMessageFactory.newMessage(fixedHeader, null, null); + + MqttReasonCodeAndPropertiesVariableHeader variableHeader = null; + if (options.getVersion() == 5) { + variableHeader = new MqttReasonCodeAndPropertiesVariableHeader( + code == null ? MqttDisconnectReasonCode.NORMAL.value() : code.value(), + properties == null ? MqttProperties.NO_PROPERTIES : properties); + } + io.netty.handler.codec.mqtt.MqttMessage disconnect = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); connection.writeMessage(disconnect); } connection.close(); @@ -375,6 +495,11 @@ public MqttClient disconnect(Handler> disconnectHandler) { */ @Override public Future publish(String topic, Buffer payload, MqttQoS qosLevel, boolean isDup, boolean isRetain) { + return publish(topic, payload, qosLevel, isDup, isRetain, MqttProperties.NO_PROPERTIES); + } + + @Override + public Future publish(String topic, Buffer payload, MqttQoS qosLevel, boolean isDup, boolean isRetain, MqttProperties properties) { if (MqttQoS.FAILURE == qosLevel) { throw new IllegalArgumentException("QoS level must be one of AT_MOST_ONCE, AT_LEAST_ONCE or EXACTLY_ONCE"); @@ -383,6 +508,20 @@ public Future publish(String topic, Buffer payload, MqttQoS qosLevel, b io.netty.handler.codec.mqtt.MqttMessage publish; MqttPublishVariableHeader variableHeader; synchronized (this) { + // MQTT 5.0: reject if QoS exceeds server's advertised Maximum QoS + if (options.getVersion() == 5 && qosLevel.value() > serverMaxQos) { + String msg = String.format("Server does not support QoS %d (server maximum QoS is %d)", qosLevel.value(), serverMaxQos); + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_QOS_UNSUPPORTED, msg)); + } + + // MQTT 5.0: reject if server's Receive Maximum is already reached + if (options.getVersion() == 5 && qosLevel != AT_MOST_ONCE && countInflightQueue >= serverReceiveMaximum) { + String msg = String.format("Server Receive Maximum of %d in-flight messages reached", serverReceiveMaximum); + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_INFLIGHT_QUEUE_FULL, msg)); + } + if (countInflightQueue >= options.getMaxInflightQueue()) { String msg = String.format("Attempt to exceed the limit of %d inflight messages", options.getMaxInflightQueue()); log.error(msg); @@ -397,6 +536,23 @@ public Future publish(String topic, Buffer payload, MqttQoS qosLevel, b return ctx.failedFuture(exception); } + // MQTT 5.0 §3.2.2.3.6: client MUST NOT send a packet exceeding server's Maximum Packet Size + if (options.getVersion() == 5 && serverMaximumPacketSize != Long.MAX_VALUE) { + byte[] topicBytes = topic.getBytes(StandardCharsets.UTF_8); + long estimatedSize = 5L // fixed header (1) + max remaining-length VBI (4) + + 2 + topicBytes.length // topic name: 2-byte length prefix + UTF-8 bytes + + (qosLevel != AT_MOST_ONCE ? 2 : 0) // packet identifier (QoS 1/2 only) + + estimatePropertiesEncodedSize(properties) // MQTT5 properties + + (payload != null ? payload.length() : 0); + if (estimatedSize > serverMaximumPacketSize) { + String msg = String.format( + "Packet size estimate %d exceeds server Maximum Packet Size of %d", + estimatedSize, serverMaximumPacketSize); + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_PACKET_TOO_LARGE, msg)); + } + } + MqttFixedHeader fixedHeader = new MqttFixedHeader( MqttMessageType.PUBLISH, isDup, @@ -405,7 +561,35 @@ public Future publish(String topic, Buffer payload, MqttQoS qosLevel, b 0 ); ByteBuf buf = Unpooled.copiedBuffer(payload.getBytes()); - variableHeader = new MqttPublishVariableHeader(topic, nextMessageId()); + String wireTopicName = topic; + MqttProperties effectiveProperties; + if (options.getVersion() == 5) { + effectiveProperties = new MqttProperties(); + // Copy caller-provided properties into the new instance + if (properties != null && properties != MqttProperties.NO_PROPERTIES) { + for (MqttProperties.MqttProperty p : properties.listAll()) { + effectiveProperties.add(p); + } + } + // Automatic topic alias management (MQTT 5.0 spec §3.3.2.3.4) + if (serverTopicAliasMaximum > 0) { + Integer alias = topicAlias.get(topic); + if (alias != null) { + // Already mapped: send empty topic name + alias (bandwidth saving) + wireTopicName = ""; + effectiveProperties.add(new IntegerProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value(), alias)); + } else if (topicAlias.size() < serverTopicAliasMaximum) { + // New topic: assign next alias, send full topic name + alias + int newAlias = topicAlias.size() + 1; + topicAlias.put(topic, newAlias); + effectiveProperties.add(new IntegerProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value(), newAlias)); + } + // else: exhausted all aliases, send full topic name without alias + } + } else { + effectiveProperties = MqttProperties.NO_PROPERTIES; + } + variableHeader = new MqttPublishVariableHeader(wireTopicName, nextMessageId(), effectiveProperties); publish = MqttMessageFactory.newMessage(fixedHeader, variableHeader, buf); switch (qosLevel) { case AT_LEAST_ONCE: @@ -480,6 +664,45 @@ private synchronized Handler publishCompletionUnknownPacketIdHandler() return this.publishCompletionPhantomHandler; } + /** + * {@inheritDoc} + */ + @Override + public MqttClient publishAckMessageHandler(Handler handler) { + this.publishAckMessageHandler = handler; + return this; + } + + private synchronized Handler publishAckMessageHandler() { + return this.publishAckMessageHandler; + } + + /** + * {@inheritDoc} + */ + @Override + public MqttClient publishRecMessageHandler(Handler handler) { + this.publishRecMessageHandler = handler; + return this; + } + + private synchronized Handler publishRecMessageHandler() { + return this.publishRecMessageHandler; + } + + /** + * {@inheritDoc} + */ + @Override + public MqttClient publishCompMessageHandler(Handler handler) { + this.publishCompMessageHandler = handler; + return this; + } + + private synchronized Handler publishCompMessageHandler() { + return this.publishCompMessageHandler; + } + /** * See {@link MqttClient#publishHandler(Handler)} for more details */ @@ -529,6 +752,40 @@ public MqttClient subscribe(String topic, int qos, Handler> */ @Override public Future subscribe(Map topics) { + return subscribe(topics, MqttProperties.NO_PROPERTIES); + } + + @Override + public Future subscribe(Map topics, MqttProperties properties) { + + // MQTT 5.0 §3.3.4: Subscription Identifier may only be used with MQTT 5.0 and only + // when the server has not explicitly disabled it (CONNACK SUBSCRIPTION_IDENTIFIER_AVAILABLE=0). + if (properties != null && properties.getProperty(MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value()) != null) { + if (options.getVersion() != 5) { + String msg = "Subscription Identifier is only available in MQTT 5.0"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED, msg)); + } + if (!serverSubscriptionIdentifierAvailable) { + String msg = "Server does not support Subscription Identifiers (CONNACK SUBSCRIPTION_IDENTIFIER_AVAILABLE=0)"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED, msg)); + } + } + + // MQTT 5.0 §3.2.2.3.11: Wildcard Subscriptions not supported when server sent WILDCARD_SUBSCRIPTION_AVAILABLE=0 + if (!serverWildcardSubscriptionAvailable && topics.keySet().stream().anyMatch(t -> t.contains("+") || t.contains("#"))) { + String msg = "Server does not support Wildcard Subscriptions (CONNACK WILDCARD_SUBSCRIPTION_AVAILABLE=0)"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED, msg)); + } + + // MQTT 5.0 §3.2.2.3.14: Shared Subscriptions not supported when server sent SHARED_SUBSCRIPTION_AVAILABLE=0 + if (!serverSharedSubscriptionAvailable && topics.keySet().stream().anyMatch(t -> t.startsWith("$share/"))) { + String msg = "Server does not support Shared Subscriptions (CONNACK SHARED_SUBSCRIPTION_AVAILABLE=0)"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_SHARED_SUBSCRIPTIONS_NOT_SUPPORTED, msg)); + } Map invalidTopics = topics.entrySet() .stream() @@ -549,7 +806,12 @@ public Future subscribe(Map topics) { false, 0); - MqttMessageIdVariableHeader variableHeader = new MqttMessageIdAndPropertiesVariableHeader(nextMessageId(), MqttProperties.NO_PROPERTIES); + MqttMessageIdVariableHeader variableHeader; + if (options.getVersion() == 5) { + variableHeader = new MqttMessageIdAndPropertiesVariableHeader(nextMessageId(), properties == null ? MqttProperties.NO_PROPERTIES : properties); + } else { + variableHeader = MqttMessageIdVariableHeader.from(nextMessageId()); + } List subscriptions = topics.entrySet() .stream() .map(e -> new MqttTopicSubscription(e.getKey(), valueOf(e.getValue()))) @@ -562,6 +824,69 @@ public Future subscribe(Map topics) { return this.write(subscribe).map(variableHeader.messageId()); } + @Override + public Future subscribe(List subscriptions, MqttProperties properties) { + + // MQTT 5.0 §3.3.4: Subscription Identifier may only be used with MQTT 5.0 and only + // when the server has not explicitly disabled it (CONNACK SUBSCRIPTION_IDENTIFIER_AVAILABLE=0). + if (properties != null && properties.getProperty(MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value()) != null) { + if (options.getVersion() != 5) { + String msg = "Subscription Identifier is only available in MQTT 5.0"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED, msg)); + } + if (!serverSubscriptionIdentifierAvailable) { + String msg = "Server does not support Subscription Identifiers (CONNACK SUBSCRIPTION_IDENTIFIER_AVAILABLE=0)"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED, msg)); + } + } + + // MQTT 5.0 §3.2.2.3.11: Wildcard Subscriptions not supported when server sent WILDCARD_SUBSCRIPTION_AVAILABLE=0 + if (!serverWildcardSubscriptionAvailable && subscriptions.stream().map(MqttTopicSubscription::topicName).anyMatch(t -> t.contains("+") || t.contains("#"))) { + String msg = "Server does not support Wildcard Subscriptions (CONNACK WILDCARD_SUBSCRIPTION_AVAILABLE=0)"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED, msg)); + } + + // MQTT 5.0 §3.2.2.3.14: Shared Subscriptions not supported when server sent SHARED_SUBSCRIPTION_AVAILABLE=0 + if (!serverSharedSubscriptionAvailable && subscriptions.stream().map(MqttTopicSubscription::topicName).anyMatch(t -> t.startsWith("$share/"))) { + String msg = "Server does not support Shared Subscriptions (CONNACK SHARED_SUBSCRIPTION_AVAILABLE=0)"; + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_SHARED_SUBSCRIPTIONS_NOT_SUPPORTED, msg)); + } + + List invalidTopics = subscriptions.stream() + .map(MqttTopicSubscription::topicName) + .filter(t -> !isValidTopicFilter(t)) + .collect(Collectors.toList()); + + if (!invalidTopics.isEmpty()) { + String msg = String.format("Invalid Topic Filters: %s", invalidTopics); + log.error(msg); + return ctx.failedFuture(new MqttException(MqttException.MQTT_INVALID_TOPIC_FILTER, msg)); + } + + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.SUBSCRIBE, + false, + AT_LEAST_ONCE, + false, + 0); + + MqttMessageIdVariableHeader variableHeader; + if (options.getVersion() == 5) { + variableHeader = new MqttMessageIdAndPropertiesVariableHeader(nextMessageId(), properties == null ? MqttProperties.NO_PROPERTIES : properties); + } else { + variableHeader = MqttMessageIdVariableHeader.from(nextMessageId()); + } + + MqttSubscribePayload payload = new MqttSubscribePayload(subscriptions); + io.netty.handler.codec.mqtt.MqttMessage subscribe = MqttMessageFactory.newMessage(fixedHeader, variableHeader, payload); + + return this.write(subscribe).map(variableHeader.messageId()); + } + /** * See {@link MqttClient#subscribe(Map, Handler)} for more details */ @@ -585,6 +910,12 @@ public MqttClient unsubscribeCompletionHandler(Handler unsubscribeCompl return this; } + @Override + public MqttClient unsubscribeCompletionMessageHandler(Handler unsubscribeCompletionMessageHandler) { + this.unsubscribeCompletionMessageHandler = unsubscribeCompletionMessageHandler; + return this; + } + /** * Unsubscribe from receiving messages on given topic * @@ -601,6 +932,10 @@ private synchronized Handler unsubscribeCompletionHandler() { return this.unsubscribeCompletionHandler; } + private synchronized Handler unsubscribeCompletionMessageHandler() { + return this.unsubscribeCompletionMessageHandler; + } + /** * See {@link MqttClient#unsubscribe(String, Handler)} )} for more details */ @@ -619,6 +954,11 @@ public MqttClient unsubscribe(String topic, Handler> unsubs */ @Override public Future unsubscribe(List topics) { + return unsubscribe(topics, MqttProperties.NO_PROPERTIES); + } + + @Override + public Future unsubscribe(List topics, MqttProperties properties) { MqttFixedHeader fixedHeader = new MqttFixedHeader( MqttMessageType.UNSUBSCRIBE, @@ -627,7 +967,12 @@ public Future unsubscribe(List topics) { false, 0); - MqttMessageIdVariableHeader variableHeader = new MqttMessageIdAndPropertiesVariableHeader(nextMessageId(), MqttProperties.NO_PROPERTIES); + MqttMessageIdVariableHeader variableHeader; + if (options.getVersion() == 5) { + variableHeader = new MqttMessageIdAndPropertiesVariableHeader(nextMessageId(), properties == null ? MqttProperties.NO_PROPERTIES : properties); + } else { + variableHeader = MqttMessageIdVariableHeader.from(nextMessageId()); + } MqttUnsubscribePayload payload = new MqttUnsubscribePayload(topics); @@ -694,6 +1039,19 @@ private synchronized Handler closeHandler() { return this.closeHandler; } + /** + * {@inheritDoc} + */ + @Override + public synchronized MqttClient disconnectMessageHandler(Handler handler) { + this.disconnectMessageHandler = handler; + return this; + } + + private synchronized Handler disconnectMessageHandler() { + return this.disconnectMessageHandler; + } + private class Ping { final long id; private Ping(long id) { @@ -831,6 +1189,112 @@ private void publishRelease(int publishMessageId) { this.write(pubrel); } + @Override + public Future publishAcknowledge(int publishMessageId, MqttPubAckReasonCode reasonCode, MqttProperties properties) { + Promise promise = vertx.getOrCreateContext().promise(); + if (this.status != Status.CONNECTED) { + promise.fail(new IllegalStateException("Client not connected")); + return promise.future(); + } + MqttFixedHeader fixedHeader = + new MqttFixedHeader(MqttMessageType.PUBACK, false, AT_MOST_ONCE, false, 0); + + io.netty.handler.codec.mqtt.MqttMessage puback; + if (options.getVersion() == 5) { + MqttPubReplyMessageVariableHeader variableHeader = new MqttPubReplyMessageVariableHeader( + publishMessageId, + reasonCode == null ? MqttPubAckReasonCode.SUCCESS.value() : reasonCode.value(), + properties == null ? MqttProperties.NO_PROPERTIES : properties); + puback = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } else { + MqttMessageIdVariableHeader variableHeader = + MqttMessageIdVariableHeader.from(publishMessageId); + puback = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } + + this.write(puback).onComplete(promise); + return promise.future(); + } + + @Override + public Future publishReceived(int publishMessageId, MqttPubRecReasonCode reasonCode, MqttProperties properties) { + Promise promise = vertx.getOrCreateContext().promise(); + if (this.status != Status.CONNECTED) { + promise.fail(new IllegalStateException("Client not connected")); + return promise.future(); + } + MqttFixedHeader fixedHeader = + new MqttFixedHeader(MqttMessageType.PUBREC, false, AT_MOST_ONCE, false, 0); + + io.netty.handler.codec.mqtt.MqttMessage pubrec; + if (options.getVersion() == 5) { + MqttPubReplyMessageVariableHeader variableHeader = new MqttPubReplyMessageVariableHeader( + publishMessageId, + reasonCode == null ? MqttPubRecReasonCode.SUCCESS.value() : reasonCode.value(), + properties == null ? MqttProperties.NO_PROPERTIES : properties); + pubrec = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } else { + MqttMessageIdVariableHeader variableHeader = + MqttMessageIdVariableHeader.from(publishMessageId); + pubrec = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } + this.write(pubrec).onComplete(promise); + return promise.future(); + } + + @Override + public Future publishComplete(int publishMessageId, MqttPubCompReasonCode reasonCode, MqttProperties properties) { + Promise promise = vertx.getOrCreateContext().promise(); + if (this.status != Status.CONNECTED) { + promise.fail(new IllegalStateException("Client not connected")); + return promise.future(); + } + MqttFixedHeader fixedHeader = + new MqttFixedHeader(MqttMessageType.PUBCOMP, false, AT_MOST_ONCE, false, 0); + + io.netty.handler.codec.mqtt.MqttMessage pubcomp; + if (options.getVersion() == 5) { + MqttPubReplyMessageVariableHeader variableHeader = new MqttPubReplyMessageVariableHeader( + publishMessageId, + reasonCode == null ? MqttPubCompReasonCode.SUCCESS.value() : reasonCode.value(), + properties == null ? MqttProperties.NO_PROPERTIES : properties); + pubcomp = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } else { + MqttMessageIdVariableHeader variableHeader = + MqttMessageIdVariableHeader.from(publishMessageId); + pubcomp = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } + this.write(pubcomp).onComplete(promise); + return promise.future(); + } + + @Override + public Future publishRelease(int publishMessageId, MqttPubRelReasonCode reasonCode, MqttProperties properties) { + Promise promise = vertx.getOrCreateContext().promise(); + if (this.status != Status.CONNECTED) { + promise.fail(new IllegalStateException("Client not connected")); + return promise.future(); + } + MqttFixedHeader fixedHeader = + new MqttFixedHeader(MqttMessageType.PUBREL, false, MqttQoS.AT_LEAST_ONCE, false, 0); + + io.netty.handler.codec.mqtt.MqttMessage pubrel; + if (options.getVersion() == 5) { + MqttPubReplyMessageVariableHeader variableHeader = new MqttPubReplyMessageVariableHeader( + publishMessageId, + reasonCode == null ? MqttPubRelReasonCode.SUCCESS.value() : reasonCode.value(), + properties == null ? MqttProperties.NO_PROPERTIES : properties); + pubrel = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } else { + MqttMessageIdVariableHeader variableHeader = + MqttMessageIdVariableHeader.from(publishMessageId); + pubrel = MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + } + + this.write(pubrel).onComplete(promise); + return promise.future(); + } + private void initChannel(NetSocketInternal sock) { ChannelPipeline pipeline = sock.channelHandlerContext().pipeline(); @@ -856,7 +1320,7 @@ private void initChannel(NetSocketInternal sock) { @Override protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) { if (evt.state() == IdleState.WRITER_IDLE) { - // verify that server is still connected (e.g. when using QoS-0) + // verify that server is still connected (e.g. when only publishing QoS-0 messages) ping(); } } @@ -881,9 +1345,9 @@ private synchronized NetSocketInternal connection() { } private Future write(io.netty.handler.codec.mqtt.MqttMessage mqttMessage) { - if(log.isDebugEnabled()){ - log.debug(String.format("Sending packet %s", mqttMessage)); - } + if (log.isDebugEnabled()) { + log.debug(String.format("Sending packet %s", mqttMessage)); + } return this.connection().writeMessage(mqttMessage); } @@ -891,11 +1355,17 @@ private Future write(io.netty.handler.codec.mqtt.MqttMessage mqttMessage) * Used for calling the close handler when the remote MQTT server closes the connection */ private void handleClosed() { + String pendingRedirect; + MqttDisconnectMessage pendingDisconnectMessage; Promise connectPromise; Promise disconnectPromise; NetClient client; Deque pings; synchronized (this) { + pendingRedirect = this.pendingRedirect; + this.pendingRedirect = null; + pendingDisconnectMessage = this.pendingDisconnectMessage; + this.pendingDisconnectMessage = null; client = this.client; connectPromise = this.connectPromise; disconnectPromise = this.disconnectPromise; @@ -914,6 +1384,26 @@ private void handleClosed() { ping.cancel(); }); + // MQTT 5.0 server redirect: reconnect transparently to the referenced server + if (pendingRedirect != null) { + String[] target = pickServer(pendingRedirect); + if (target != null) { + int redirectPort = Integer.parseInt(target[1]); + String redirectHost = target[0]; + log.info("DISCONNECT SERVER_REFERENCE redirect to " + redirectHost + ":" + redirectPort); + disconnectPromise.complete(); + client.close(); + this.connect(redirectPort, redirectHost); + return; // do NOT fire the user's closeHandler + } + } + + if (pendingDisconnectMessage != null) { + Handler disconnectHandler = disconnectMessageHandler(); + if (disconnectHandler != null) { + disconnectHandler.handle(pendingDisconnectMessage); + } + } Handler handler = closeHandler(); if (handler != null) { handler.handle(null); @@ -943,13 +1433,16 @@ private void handleMessage(ChannelHandlerContext chctx, Object msg) { chctx.pipeline().fireExceptionCaught(result.cause()); return; } + if (!result.isFinished()) { chctx.pipeline().fireExceptionCaught(new Exception("Unfinished message")); return; } - if(log.isDebugEnabled()){ - log.debug(String.format("Incoming packet %s", msg)); + + if (log.isDebugEnabled()) { + log.debug(String.format("Incoming packet %s", msg)); } + switch (mqttMessage.fixedHeader().messageType()) { case CONNACK: @@ -958,7 +1451,8 @@ private void handleMessage(ChannelHandlerContext chctx, Object msg) { MqttConnAckMessage mqttConnAckMessage = MqttConnAckMessage.create( connack.variableHeader().connectReturnCode(), - connack.variableHeader().isSessionPresent()); + connack.variableHeader().isSessionPresent(), + connack.variableHeader().properties()); handleConnack(mqttConnAckMessage); break; @@ -967,50 +1461,127 @@ private void handleMessage(ChannelHandlerContext chctx, Object msg) { io.netty.handler.codec.mqtt.MqttPublishMessage publish = (io.netty.handler.codec.mqtt.MqttPublishMessage) mqttMessage; ByteBuf newBuf = VertxHandler.safeBuffer(publish.payload()); + // MQTT 5.0 §3.3.2.3.4 – resolve incoming topic alias (server→client direction) + String resolvedTopic = publish.variableHeader().topicName(); + MqttProperties.MqttProperty incomingAliasProp = + publish.variableHeader().properties().getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value()); + if (incomingAliasProp != null) { + int alias = (Integer) incomingAliasProp.value(); + int clientMax = options.getTopicAliasMaximum() != null ? options.getTopicAliasMaximum() : 0; + if (alias < 1 || alias > clientMax) { + // Alias out of range – protocol error + disconnect(MqttDisconnectReasonCode.TOPIC_ALIAS_INVALID, MqttProperties.NO_PROPERTIES); + return; + } + if (!resolvedTopic.isEmpty()) { + // New mapping or overwrite + synchronized (this) { serverTopicAlias.put(alias, resolvedTopic); } + } else { + // Alias-only: look up stored mapping + synchronized (this) { resolvedTopic = serverTopicAlias.get(alias); } + if (resolvedTopic == null) { + // Alias used before being defined – protocol error + disconnect(MqttDisconnectReasonCode.TOPIC_ALIAS_INVALID, MqttProperties.NO_PROPERTIES); + return; + } + } + } + MqttPublishMessage mqttPublishMessage = MqttPublishMessage.create( publish.variableHeader().packetId(), publish.fixedHeader().qosLevel(), publish.fixedHeader().isDup(), publish.fixedHeader().isRetain(), - publish.variableHeader().topicName(), - newBuf); + resolvedTopic, + newBuf, + publish.variableHeader().properties()); handlePublish(mqttPublishMessage); break; - case PUBACK: - handlePuback(((MqttMessageIdVariableHeader) mqttMessage.variableHeader()).messageId()); + case PUBACK: { + MqttMessageIdVariableHeader pubackVh = (MqttMessageIdVariableHeader) mqttMessage.variableHeader(); + if (options.getVersion() == 5 && pubackVh instanceof MqttPubReplyMessageVariableHeader) { + MqttPubReplyMessageVariableHeader rich = (MqttPubReplyMessageVariableHeader) pubackVh; + handlePuback(rich.messageId(), MqttPubAckReasonCode.valueOf((byte) rich.reasonCode()), rich.properties()); + } else { + handlePuback(pubackVh.messageId(), MqttPubAckReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES); + } break; + } - case PUBREC: - handlePubrec(((MqttMessageIdVariableHeader) mqttMessage.variableHeader()).messageId()); + case PUBREC: { + MqttMessageIdVariableHeader pubrecVh = (MqttMessageIdVariableHeader) mqttMessage.variableHeader(); + if (options.getVersion() == 5 && pubrecVh instanceof MqttPubReplyMessageVariableHeader) { + MqttPubReplyMessageVariableHeader rich = (MqttPubReplyMessageVariableHeader) pubrecVh; + handlePubrec(rich.messageId(), MqttPubRecReasonCode.valueOf((byte) rich.reasonCode()), rich.properties()); + } else { + handlePubrec(pubrecVh.messageId(), MqttPubRecReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES); + } break; + } case PUBREL: handlePubrel(((MqttMessageIdVariableHeader) mqttMessage.variableHeader()).messageId()); break; - case PUBCOMP: - handlePubcomp(((MqttMessageIdVariableHeader) mqttMessage.variableHeader()).messageId()); + case PUBCOMP: { + MqttMessageIdVariableHeader pubcompVh = (MqttMessageIdVariableHeader) mqttMessage.variableHeader(); + if (options.getVersion() == 5 && pubcompVh instanceof MqttPubReplyMessageVariableHeader) { + MqttPubReplyMessageVariableHeader rich = (MqttPubReplyMessageVariableHeader) pubcompVh; + handlePubcomp(rich.messageId(), MqttPubCompReasonCode.valueOf((byte) rich.reasonCode()), rich.properties()); + } else { + handlePubcomp(pubcompVh.messageId(), MqttPubCompReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES); + } break; + } case SUBACK: - io.netty.handler.codec.mqtt.MqttSubAckMessage unsuback = (io.netty.handler.codec.mqtt.MqttSubAckMessage) mqttMessage; + io.netty.handler.codec.mqtt.MqttSubAckMessage suback = (io.netty.handler.codec.mqtt.MqttSubAckMessage) mqttMessage; + + io.netty.handler.codec.mqtt.MqttProperties subackProps = io.netty.handler.codec.mqtt.MqttProperties.NO_PROPERTIES; + if (suback.variableHeader() instanceof io.netty.handler.codec.mqtt.MqttMessageIdAndPropertiesVariableHeader) { + subackProps = ((io.netty.handler.codec.mqtt.MqttMessageIdAndPropertiesVariableHeader) suback.variableHeader()).properties(); + } MqttSubAckMessage mqttSubAckMessage = MqttSubAckMessage.create( - unsuback.variableHeader().messageId(), - unsuback.payload().grantedQoSLevels()); + suback.variableHeader().messageId(), + suback.payload().grantedQoSLevels(), + subackProps); handleSuback(mqttSubAckMessage); break; case UNSUBACK: - handleUnsuback(((MqttMessageIdVariableHeader) mqttMessage.variableHeader()).messageId()); + handleUnsuback(mqttMessage); break; case PINGRESP: handlePingresp(); break; + case DISCONNECT: + // MQTT 5.0: server-initiated DISCONNECT – capture reason code and check for SERVER_REFERENCE redirect + if (options.getVersion() == 5 + && mqttMessage.variableHeader() instanceof MqttReasonCodeAndPropertiesVariableHeader) { + MqttReasonCodeAndPropertiesVariableHeader disconnVarHeader = + (MqttReasonCodeAndPropertiesVariableHeader) mqttMessage.variableHeader(); + MqttDisconnectReasonCode disconnectReasonCode = + MqttDisconnectReasonCode.valueOf((byte) disconnVarHeader.reasonCode()); + MqttDisconnectMessage disconnectMsg = + MqttDisconnectMessage.create(disconnectReasonCode, disconnVarHeader.properties()); + synchronized (this) { + this.pendingDisconnectMessage = disconnectMsg; + if (options.isAutoServerRedirect()) { + MqttProperties.MqttProperty serverRefProp = + disconnVarHeader.properties().getProperty(MqttProperties.MqttPropertyType.SERVER_REFERENCE.value()); + if (serverRefProp != null) { + this.pendingRedirect = (String) serverRefProp.value(); + } + } + } + } + break; + default: chctx.pipeline().fireExceptionCaught(new Exception("Wrong message type " + msg.getClass().getName())); @@ -1018,9 +1589,9 @@ private void handleMessage(ChannelHandlerContext chctx, Object msg) { } } else { - chctx.pipeline().fireExceptionCaught(new Exception("Wrong message type")); } + } /** @@ -1042,13 +1613,37 @@ private void handlePingresp() { /** * Used for calling the unsuback handler when the server acks an unsubscribe * - * @param unsubackMessageId identifier of the subscribe acknowledged by the server + * @param msg message acknowledged by the server */ - private void handleUnsuback(int unsubackMessageId) { + private void handleUnsuback(io.netty.handler.codec.mqtt.MqttMessage msg) { - Handler handler = unsubscribeCompletionHandler(); - if (handler != null) { - handler.handle(unsubackMessageId); + int unsubackMessageId; + MqttProperties properties = MqttProperties.NO_PROPERTIES; + + if (msg.variableHeader() instanceof io.netty.handler.codec.mqtt.MqttMessageIdAndPropertiesVariableHeader) { + io.netty.handler.codec.mqtt.MqttMessageIdAndPropertiesVariableHeader variableHeader = + (io.netty.handler.codec.mqtt.MqttMessageIdAndPropertiesVariableHeader) msg.variableHeader(); + unsubackMessageId = variableHeader.messageId(); + properties = variableHeader.properties(); + } else { + unsubackMessageId = ((io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader) msg.variableHeader()).messageId(); + } + + java.util.List reasonCodes = java.util.Collections.emptyList(); + if (msg.payload() instanceof io.netty.handler.codec.mqtt.MqttUnsubAckPayload) { + reasonCodes = ((io.netty.handler.codec.mqtt.MqttUnsubAckPayload) msg.payload()).unsubscribeReasonCodes(); + } + + synchronized (this) { + Handler messageHandler = unsubscribeCompletionMessageHandler(); + if (messageHandler != null) { + messageHandler.handle(MqttUnsubAckMessage.create(unsubackMessageId, reasonCodes, properties)); + } + + Handler handler = unsubscribeCompletionHandler(); + if (handler != null) { + handler.handle(unsubackMessageId); + } } } @@ -1056,8 +1651,10 @@ private void handleUnsuback(int unsubackMessageId) { * Used for calling the puback handler when the server acknowledge a QoS 1 message with puback * * @param pubackMessageId identifier of the message acknowledged by the server + * @param reasonCode MQTT5 reason code (SUCCESS for MQTT3) + * @param properties MQTT5 properties (NO_PROPERTIES for MQTT3) */ - private void handlePuback(int pubackMessageId) { + private void handlePuback(int pubackMessageId, MqttPubAckReasonCode reasonCode, MqttProperties properties) { synchronized (this) { @@ -1075,6 +1672,10 @@ private void handlePuback(int pubackMessageId) { removedPacket.cancelTimer(); countInflightQueue--; } + Handler ackHandler = publishAckMessageHandler(); + if (ackHandler != null) { + ackHandler.handle(MqttPubAckMessage.create(pubackMessageId, reasonCode, properties)); + } Handler handler = publishCompletionHandler(); if (handler != null) { handler.handle(pubackMessageId); @@ -1090,7 +1691,7 @@ private void handlePubackTimeout(int packetId) { // the message has already been ACKed log.debug("PUBLISH expiration timer fired but QoS 1 message has already been PUBACKed by server"); return; - } + } } countInflightQueue--; Handler handler = publishCompletionExpirationHandler(); @@ -1103,8 +1704,10 @@ private void handlePubackTimeout(int packetId) { * Used for calling the pubcomp handler when the server client acknowledge a QoS 2 message with pubcomp * * @param pubcompMessageId identifier of the message acknowledged by the server + * @param reasonCode MQTT5 reason code (SUCCESS for MQTT3) + * @param properties MQTT5 properties (NO_PROPERTIES for MQTT3) */ - private void handlePubcomp(int pubcompMessageId) { + private void handlePubcomp(int pubcompMessageId, MqttPubCompReasonCode reasonCode, MqttProperties properties) { synchronized (this) { ExpiringPacket removedPacket = qos2outbound.remove(pubcompMessageId); @@ -1120,6 +1723,10 @@ private void handlePubcomp(int pubcompMessageId) { removedPacket.cancelTimer(); countInflightQueue--; } + Handler compHandler = publishCompMessageHandler(); + if (compHandler != null) { + compHandler.handle(MqttPubCompMessage.create(pubcompMessageId, reasonCode, properties)); + } Handler handler = publishCompletionHandler(); if (handler != null) { handler.handle(pubcompMessageId); @@ -1147,8 +1754,10 @@ private void handlePubcompTimeout(int packetId) { * Used for sending the pubrel when a pubrec is received from the server * * @param pubrecMessageId identifier of the message acknowledged by server + * @param reasonCode MQTT5 reason code (SUCCESS for MQTT3) + * @param properties MQTT5 properties (NO_PROPERTIES for MQTT3) */ - private void handlePubrec(int pubrecMessageId) { + private void handlePubrec(int pubrecMessageId, MqttPubRecReasonCode reasonCode, MqttProperties properties) { synchronized (this) { ExpiringPacket removedPacket = qos2outbound.remove(pubrecMessageId); @@ -1163,6 +1772,10 @@ private void handlePubrec(int pubrecMessageId) { } removedPacket.cancelTimer(); } + Handler recHandler = publishRecMessageHandler(); + if (recHandler != null) { + recHandler.handle(MqttPubRecMessage.create(pubrecMessageId, reasonCode, properties)); + } this.publishRelease(pubrecMessageId); } @@ -1229,7 +1842,7 @@ private void handlePublish(MqttPublishMessage msg) { // we will handle the PUBCOMP when a PUBREL comes break; - } + } } @@ -1269,10 +1882,11 @@ private void handlePubrel(int pubrelMessageId) { */ private void handleConnack(MqttConnAckMessage msg) { - Status status = msg.code() == MqttConnectReturnCode.CONNECTION_ACCEPTED ? Status.CONNECTED : Status.CLOSING; - - if (msg.code() == MqttConnectReturnCode.CONNECTION_ACCEPTED) { + // Apply MQTT 5.0 server-assigned properties before completing the promise + if (options.getVersion() == 5) { + applyConnAckProperties(msg); + } NetSocketInternal connection; Promise connectPromise; synchronized (this) { @@ -1284,6 +1898,12 @@ private void handleConnack(MqttConnAckMessage msg) { connection.closeHandler(v -> handleClosed()); connectPromise.complete(msg); } else { + // Check for MQTT 5.0 server redirect before resetting state + String serverRef = null; + if (options.getVersion() == 5 && options.isAutoServerRedirect()) { + serverRef = msg.serverReference(); + } + Promise connectPromise; Promise disconnectPromise; NetSocketInternal connection; @@ -1301,6 +1921,24 @@ private void handleConnack(MqttConnAckMessage msg) { this.client = null; } connection.closeHandler(null); + + if (serverRef != null) { + String[] target = pickServer(serverRef); + if (target != null) { + int redirectPort = Integer.parseInt(target[1]); + String redirectHost = target[0]; + log.info("CONNACK SERVER_REFERENCE redirect to " + redirectHost + ":" + redirectPort); + disconnectPromise.complete(); + client.close(); + this.connect(redirectPort, redirectHost) + .onComplete(ar -> { + if (ar.succeeded()) connectPromise.complete(ar.result()); + else connectPromise.fail(ar.cause()); + }); + return; + } + } + MqttConnectionException exception = new MqttConnectionException(msg.code()); log.error(String.format("Connection refused by the server - code: %s", msg.code())); connectPromise.fail(exception); @@ -1309,6 +1947,112 @@ private void handleConnack(MqttConnAckMessage msg) { } } + /** + * Applies MQTT 5.0 CONNACK properties sent by the server to the local client state. + *

+ * Per the MQTT 5.0 specification, certain server-provided values MUST override + * what the client requested in the CONNECT packet: + *

    + *
  • Assigned Client Identifier: stored in options so {@link #clientId()} reflects it
  • + *
  • Server Keep Alive: replaces the keep-alive interval the client requested
  • + *
+ * Other properties (Receive Maximum, Max Packet Size, Max QoS, Retain Available, + * Topic Alias Maximum, Reason String, User Properties, Response Information, + * Server Reference, Auth Method/Data) are available to the caller via the + * {@link MqttConnAckMessage} returned from the connect Future. + */ + private void applyConnAckProperties(MqttConnAckMessage msg) { + // Receive Maximum: max concurrent QoS1/2 in-flight messages the server can handle + Integer receiveMaximum = msg.receiveMaximum(); + synchronized (this) { + serverReceiveMaximum = (receiveMaximum != null && receiveMaximum > 0) ? receiveMaximum : Integer.MAX_VALUE; + } + log.debug("CONNACK serverReceiveMaximum=" + serverReceiveMaximum); + + // Maximum QoS: highest QoS level the server accepts + Integer maximumQos = msg.maximumQos(); + Long maximumPacketSize = msg.maximumPacketSize(); + synchronized (this) { + serverMaxQos = (maximumQos != null) ? maximumQos : 2; + serverMaximumPacketSize = (maximumPacketSize != null) ? maximumPacketSize : Long.MAX_VALUE; + } + log.debug("CONNACK serverMaxQos=" + serverMaxQos); + log.debug("CONNACK serverMaximumPacketSize=" + serverMaximumPacketSize); + + // Assigned Client Identifier: server assigned us an ID (we sent empty ClientID) + String assignedClientId = msg.assignedClientIdentifier(); + if (assignedClientId != null) { + options.setClientId(assignedClientId); + log.debug("Server assigned client identifier: " + assignedClientId); + } + + // Server Keep Alive: MUST use this value instead of what we sent + Integer serverKeepAlive = msg.serverKeepAlive(); + if (serverKeepAlive != null && options.getKeepAliveInterval() != serverKeepAlive) { + options.setKeepAliveInterval(serverKeepAlive); + log.debug("Server assigned keep alive: " + serverKeepAlive + "s"); + + // Update the IdleStateHandler in the pipeline with the new keep-alive interval + ChannelPipeline pipeline = connection.channelHandlerContext().pipeline(); + if (pipeline.get("idle") != null) { + pipeline.remove("idle"); + pipeline.addBefore("handler", "idle", + new IdleStateHandler(0, serverKeepAlive, 0) { + @Override + protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) { + if (evt.state() == IdleState.WRITER_IDLE) { + // verify that server is still connected (e.g. when only publishing QoS-0 messages) + ping(); + } + } + }); + } + } + + // Topic Alias Maximum: store how many topic aliases the server accepts + Integer topicAliasMaximum = msg.topicAliasMaximum(); + // Subscription Identifier Available: absent or 1 means available; 0 means NOT available + Boolean subIdAvailable = msg.subscriptionIdentifierAvailable(); + // Wildcard Subscription Available: absent or 1 means available; 0 means NOT available + Boolean wildcardAvailable = msg.wildcardSubscriptionAvailable(); + // Shared Subscription Available: absent or 1 means available; 0 means NOT available + Boolean sharedAvailable = msg.sharedSubscriptionAvailable(); + synchronized (this) { + serverTopicAliasMaximum = (topicAliasMaximum != null) ? topicAliasMaximum : 0; + topicAlias.clear(); + serverTopicAlias.clear(); + serverSubscriptionIdentifierAvailable = subIdAvailable == null || subIdAvailable; + serverWildcardSubscriptionAvailable = wildcardAvailable == null || wildcardAvailable; + serverSharedSubscriptionAvailable = sharedAvailable == null || sharedAvailable; + } + log.debug("CONNACK topicAliasMaximum=" + serverTopicAliasMaximum); + log.debug("CONNACK subscriptionIdentifierAvailable=" + serverSubscriptionIdentifierAvailable); + log.debug("CONNACK wildcardSubscriptionAvailable=" + serverWildcardSubscriptionAvailable); + log.debug("CONNACK sharedSubscriptionAvailable=" + serverSharedSubscriptionAvailable); + + // Log informational properties for debug purposes + if (log.isDebugEnabled()) { + if (msg.sessionExpiryInterval() != null) + log.debug("CONNACK sessionExpiryInterval=" + msg.sessionExpiryInterval()); + if (msg.receiveMaximum() != null) + log.debug("CONNACK receiveMaximum=" + msg.receiveMaximum()); + if (msg.maximumQos() != null) + log.debug("CONNACK maximumQos=" + msg.maximumQos()); + if (msg.retainAvailable() != null) + log.debug("CONNACK retainAvailable=" + msg.retainAvailable()); + if (msg.maximumPacketSize() != null) + log.debug("CONNACK maximumPacketSize=" + msg.maximumPacketSize()); + if (msg.reasonString() != null) + log.debug("CONNACK reasonString=" + msg.reasonString()); + if (msg.responseInformation() != null) + log.debug("CONNACK responseInformation=" + msg.responseInformation()); + if (msg.serverReference() != null) + log.debug("CONNACK serverReference=" + msg.serverReference()); + if (msg.authenticationMethod() != null) + log.debug("CONNACK authenticationMethod=" + msg.authenticationMethod()); + } + } + /** * Used for calling the exception handler when an error at connection level * @@ -1329,6 +2073,67 @@ private String generateRandomClientId() { return UUID.randomUUID().toString(); } + /** + * Parses a SERVER_REFERENCE string (comma-separated "host:port" or "host" entries) + * and returns {host, port} for a randomly chosen entry. + * IPv6 addresses enclosed in brackets are supported (e.g. "[::1]:1883"). + * + * @return String array {host, portString}, or {@code null} if the string is blank/unparseable + */ + private String[] pickServer(String serverReference) { + if (serverReference == null || serverReference.trim().isEmpty()) return null; + String[] entries = serverReference.split(","); + String entry = entries[ThreadLocalRandom.current().nextInt(entries.length)].trim(); + if (entry.isEmpty()) return null; + // IPv6 bracket notation: "[::1]:1883" or just "[::1]" + if (entry.startsWith("[")) { + int bracketEnd = entry.indexOf(']'); + if (bracketEnd < 0) return new String[]{entry, String.valueOf(MqttClientOptions.DEFAULT_PORT)}; + String host = entry.substring(1, bracketEnd); + String rest = entry.substring(bracketEnd + 1); + int port = rest.startsWith(":") ? Integer.parseInt(rest.substring(1)) : MqttClientOptions.DEFAULT_PORT; + return new String[]{host, String.valueOf(port)}; + } + // Regular "host:port" or "host" + int colonIdx = entry.lastIndexOf(':'); + if (colonIdx > 0) { + return new String[]{entry.substring(0, colonIdx), entry.substring(colonIdx + 1)}; + } + return new String[]{entry, String.valueOf(MqttClientOptions.DEFAULT_PORT)}; + } + + /** + * Estimates the encoded size of MQTT properties for a packet size check. + * Intentionally over-estimates to be conservative. + * + * @param properties the properties to estimate (may be null) + * @return estimated encoded byte count including the properties length field + */ + private int estimatePropertiesEncodedSize(MqttProperties properties) { + if (properties == null || properties == MqttProperties.NO_PROPERTIES) { + return 1; // 1 byte for zero-length Variable Byte Integer + } + int size = 4; // max Variable Byte Integer for the properties length field + for (MqttProperties.MqttProperty p : properties.listAll()) { + size += 1; // property type ID byte + if (p instanceof MqttProperties.IntegerProperty) { + size += 4; + } else if (p instanceof MqttProperties.StringProperty) { + size += 2 + ((String) p.value()).getBytes(StandardCharsets.UTF_8).length; + } else if (p instanceof MqttProperties.BinaryProperty) { + size += 2 + ((byte[]) p.value()).length; + } else if (p instanceof MqttProperties.UserProperties) { + for (MqttProperties.StringPair pair : ((MqttProperties.UserProperties) p).value()) { + size += 1 + 2 + pair.key.getBytes(StandardCharsets.UTF_8).length + + 2 + pair.value.getBytes(StandardCharsets.UTF_8).length; + } + } else { + size += 8; // generous fallback + } + } + return size; + } + /** * Check either given Topic Name valid of not * diff --git a/src/main/java/io/vertx/mqtt/impl/MqttEndpointImpl.java b/src/main/java/io/vertx/mqtt/impl/MqttEndpointImpl.java index 7201f43b..f82662db 100644 --- a/src/main/java/io/vertx/mqtt/impl/MqttEndpointImpl.java +++ b/src/main/java/io/vertx/mqtt/impl/MqttEndpointImpl.java @@ -26,6 +26,7 @@ import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; import io.netty.handler.codec.mqtt.MqttMessageType; import io.netty.handler.codec.mqtt.MqttProperties; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; import io.netty.handler.codec.mqtt.MqttPubReplyMessageVariableHeader; import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; import io.netty.handler.codec.mqtt.MqttQoS; @@ -913,7 +914,9 @@ public SSLSession sslSession() { private Future write(io.netty.handler.codec.mqtt.MqttMessage mqttMessage) { synchronized (this.conn) { - if (mqttMessage.fixedHeader().messageType() != MqttMessageType.CONNACK) { + MqttMessageType type = mqttMessage.fixedHeader().messageType(); + // CONNACK and AUTH may be sent before the connection is fully accepted + if (type != MqttMessageType.CONNACK && type != MqttMessageType.AUTH) { this.checkConnected(); } return this.conn.writeMessage(mqttMessage); @@ -940,6 +943,15 @@ private void checkConnected() { } } + /** + * Used for calling the auth handler when an AUTH packet is received from the remote MQTT client + */ + void handleAuth(MqttAuthenticateReasonCode reasonCode, MqttProperties properties) { + // Stub: a full server-side AUTH handler requires the authentication-exchange API + // (introduced upstream in commit 892e923). For now, AUTH packets received before + // CONNACK are accepted by MqttServerConnection but not surfaced to user code. + } + /** * Cleanup */ diff --git a/src/main/java/io/vertx/mqtt/impl/MqttServerConnection.java b/src/main/java/io/vertx/mqtt/impl/MqttServerConnection.java index 7d4a41df..bfcbf543 100644 --- a/src/main/java/io/vertx/mqtt/impl/MqttServerConnection.java +++ b/src/main/java/io/vertx/mqtt/impl/MqttServerConnection.java @@ -47,12 +47,14 @@ import io.vertx.mqtt.messages.MqttPublishMessage; import io.vertx.mqtt.messages.MqttSubscribeMessage; import io.vertx.mqtt.messages.MqttUnsubscribeMessage; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; import io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode; import io.vertx.mqtt.messages.codes.MqttPubAckReasonCode; import io.vertx.mqtt.messages.codes.MqttPubCompReasonCode; import io.vertx.mqtt.messages.codes.MqttPubRecReasonCode; import io.vertx.mqtt.messages.codes.MqttPubRelReasonCode; +import java.util.HashMap; import java.util.UUID; /** @@ -77,6 +79,10 @@ public class MqttServerConnection { private MultiMap httpHeaders; private String httpRequestUri; + // MQTT 5.0 §3.3.2.3.4 – topic alias (client-to-server direction) + // alias → topic mapping for incoming PUBLISH messages from the client + private final HashMap clientTopicAlias = new HashMap<>(); + public MqttServerConnection(NetSocketInternal so, Handler endpointHandler, Handler exceptionHandler, @@ -150,12 +156,40 @@ void handleMessage(Object msg) { io.netty.handler.codec.mqtt.MqttPublishMessage publish = (io.netty.handler.codec.mqtt.MqttPublishMessage) mqttMessage; ByteBuf newBuf = VertxHandler.safeBuffer(publish.payload()); + // MQTT 5.0 §3.3.2.3.4 – resolve incoming topic alias (client-to-server direction) + String resolvedTopic = publish.variableHeader().topicName(); + MqttProperties.MqttProperty aliasProp = + publish.variableHeader().properties().getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value()); + if (aliasProp != null) { + int alias = (Integer) aliasProp.value(); + if (alias < 1) { + // Alias 0 is never permitted per spec §3.3.2.3.4 + log.warn("MQTT protocol error: TOPIC_ALIAS 0 is not permitted, closing connection"); + so.close(); + return; + } + if (!resolvedTopic.isEmpty()) { + // New or overwritten mapping + clientTopicAlias.put(alias, resolvedTopic); + } else { + // Alias-only packet: resolve to stored topic + resolvedTopic = clientTopicAlias.get(alias); + if (resolvedTopic == null) { + // Alias used before being defined → protocol error + log.warn("MQTT protocol error: TOPIC_ALIAS " + alias + + " used before being defined, closing connection"); + so.close(); + return; + } + } + } + MqttPublishMessage mqttPublishMessage = MqttPublishMessage.create( publish.variableHeader().packetId(), publish.fixedHeader().qosLevel(), publish.fixedHeader().isDup(), publish.fixedHeader().isRetain(), - publish.variableHeader().topicName(), + resolvedTopic, newBuf, publish.variableHeader().properties()); this.handlePublish(mqttPublishMessage); @@ -452,6 +486,18 @@ void handlePubcomp(int pubcompMessageId, MqttPubCompReasonCode code, MqttPropert } } + /** + * Used internally for handling the auth from the remote MQTT client + */ + void handleAuth(MqttAuthenticateReasonCode reasonCode, MqttProperties properties) { + synchronized (this.so) { + // AUTH is valid both before CONNACK (enhanced auth initial exchange) and after (re-auth) + if (this.endpoint != null) { + this.endpoint.handleAuth(reasonCode, properties); + } + } + } + /** * Used internally for handling the pinreq from the remote MQTT client */ diff --git a/src/main/java/io/vertx/mqtt/messages/MqttAuthenticationExchangeMessage.java b/src/main/java/io/vertx/mqtt/messages/MqttAuthenticationExchangeMessage.java new file mode 100644 index 00000000..97fe45b3 --- /dev/null +++ b/src/main/java/io/vertx/mqtt/messages/MqttAuthenticationExchangeMessage.java @@ -0,0 +1,52 @@ +package io.vertx.mqtt.messages; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.vertx.codegen.annotations.CacheReturn; +import io.vertx.codegen.annotations.GenIgnore; +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.buffer.Buffer; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; +import io.vertx.mqtt.messages.impl.MqttAuthenticationExchangeMessageImpl; + +/** + * Represents an MQTT AUTH message + */ +@VertxGen +public interface MqttAuthenticationExchangeMessage { + + /** + * Create a concrete instance of a Vert.x auth message + * + * @param reasonCode authenticate reason code + * @param properties mqtt properties. + * @return Vert.x auth message + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + static MqttAuthenticationExchangeMessage create(MqttAuthenticateReasonCode reasonCode, MqttProperties properties) { + return new MqttAuthenticationExchangeMessageImpl(reasonCode, properties); + } + + /** + * @return authenticate reason code + */ + MqttAuthenticateReasonCode reasonCode(); + + /** + * @return authenticate method + */ + @CacheReturn + String authenticationMethod(); + + /** + * @return authentication data + */ + @CacheReturn + Buffer authenticationData(); + + /** + * @return MQTT properties + */ + @CacheReturn + @GenIgnore(GenIgnore.PERMITTED_TYPE) + MqttProperties properties(); +} diff --git a/src/main/java/io/vertx/mqtt/messages/MqttConnAckMessage.java b/src/main/java/io/vertx/mqtt/messages/MqttConnAckMessage.java index 01034e9f..c26fe820 100644 --- a/src/main/java/io/vertx/mqtt/messages/MqttConnAckMessage.java +++ b/src/main/java/io/vertx/mqtt/messages/MqttConnAckMessage.java @@ -20,11 +20,18 @@ import io.netty.handler.codec.mqtt.MqttProperties; import io.vertx.codegen.annotations.CacheReturn; import io.vertx.codegen.annotations.GenIgnore; +import io.vertx.codegen.annotations.Nullable; import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.buffer.Buffer; import io.vertx.mqtt.messages.impl.MqttConnAckMessageImpl; +import java.util.Map; + /** - * Represents an MQTT CONNACK message + * Represents an MQTT CONNACK message. + *

+ * MQTT 3.1.1: exposes {@link #code()} and {@link #isSessionPresent()}. + * MQTT 5.0: additionally exposes all CONNACK properties via typed accessors. */ @VertxGen public interface MqttConnAckMessage { @@ -32,40 +39,222 @@ public interface MqttConnAckMessage { /** * Create a concrete instance of a Vert.x connack message * - * @param code return code from the connection request - * @param isSessionPresent is an old session is present - * @return + * @param code return code from the connection request + * @param isSessionPresent whether an old session is present + * @return the connack message */ static MqttConnAckMessage create(MqttConnectReturnCode code, boolean isSessionPresent) { return new MqttConnAckMessageImpl(code, isSessionPresent); } /** - * Create a concrete instance of a Vert.x connack message + * Create a concrete instance of a Vert.x connack message with MQTT properties. * - * @param code return code from the connection request - * @param isSessionPresent is an old session is present - * @param properties MQTT properties - * @return + * @param code return code from the connection request + * @param isSessionPresent whether an old session is present + * @param properties MQTT properties (MQTT 5.0) + * @return the connack message */ @GenIgnore(GenIgnore.PERMITTED_TYPE) static MqttConnAckMessage create(MqttConnectReturnCode code, boolean isSessionPresent, MqttProperties properties) { return new MqttConnAckMessageImpl(code, isSessionPresent, properties); } + // ------------------------------------------------------------------------- + // MQTT 3.1.1 fields + // ------------------------------------------------------------------------- + /** - * @return return code from the connection request + * @return return code from the connection request */ @CacheReturn MqttConnectReturnCode code(); /** - * @return is an old session is present + * @return whether an old session is present on the server */ @CacheReturn boolean isSessionPresent(); + /** + * Raw access to MQTT properties. Use the typed accessors below when possible. + * + * @return the raw MqttProperties object + */ @GenIgnore(GenIgnore.PERMITTED_TYPE) @CacheReturn MqttProperties properties(); + + // ------------------------------------------------------------------------- + // MQTT 5.0 properties — typed polyglot-friendly accessors + // ------------------------------------------------------------------------- + + /** + * Session Expiry Interval in seconds assigned by the server. (MQTT 5.0) + *

+ * If present, overrides the value sent by the client in the CONNECT packet. + * + * @return the session expiry interval, or {@code null} if not present + */ + @Nullable + Long sessionExpiryInterval(); + + /** + * Receive Maximum: the maximum number of QoS 1 and QoS 2 publications + * the server is willing to process concurrently. (MQTT 5.0) + * + * @return the receive maximum, or {@code null} if not present + */ + @Nullable + Integer receiveMaximum(); + + /** + * Maximum QoS level the server supports. (MQTT 5.0) + *

+ * 0 = QoS 0 only, 1 = QoS 0 and 1. If absent, QoS 2 is supported. + * + * @return 0 or 1, or {@code null} if absent (meaning QoS 2 is supported) + */ + @Nullable + Integer maximumQos(); + + /** + * Whether the server supports retained messages. (MQTT 5.0) + * + * @return {@code false} if the server does NOT support retain; {@code null} if absent (meaning retain IS supported) + */ + @Nullable + Boolean retainAvailable(); + + /** + * Whether the server supports Subscription Identifiers. (MQTT 5.0) + *

+ * If the server sends {@code 0}, the client MUST NOT include a + * {@code SUBSCRIPTION_IDENTIFIER} property in any SUBSCRIBE packet. + * If absent, subscription identifiers are supported (default = 1). + * + * @return {@code false} if subscription identifiers are NOT supported; + * {@code null} if absent (meaning they ARE supported) + */ + @Nullable + Boolean subscriptionIdentifierAvailable(); + + /** + * Whether the server supports Wildcard Subscriptions. (MQTT 5.0 §3.2.2.3.11) + *

+ * If the server sends {@code 0}, the client MUST NOT send SUBSCRIBE packets + * with wildcard topic filters. + * If absent, wildcard subscriptions are supported (default = 1). + * + * @return {@code false} if wildcard subscriptions are NOT supported; + * {@code null} if absent (meaning they ARE supported) + */ + @Nullable + Boolean wildcardSubscriptionAvailable(); + + /** + * Whether the server supports Shared Subscriptions. (MQTT 5.0 §3.2.2.3.14) + *

+ * If the server sends {@code 0}, the client MUST NOT send SUBSCRIBE packets + * with shared topic filters ({@code $share/...}). + * If absent, shared subscriptions are supported (default = 1). + * + * @return {@code false} if shared subscriptions are NOT supported; + * {@code null} if absent (meaning they ARE supported) + */ + @Nullable + Boolean sharedSubscriptionAvailable(); + + /** + * Maximum packet size the server is willing to accept, in bytes. (MQTT 5.0) + * + * @return the maximum packet size, or {@code null} if not present (meaning no limit) + */ + @Nullable + Long maximumPacketSize(); + + /** + * Client Identifier assigned by the server. (MQTT 5.0) + *

+ * Present only when the client connected with an empty ClientID and the + * server assigned one. + * + * @return the assigned client identifier, or {@code null} if not present + */ + @Nullable + String assignedClientIdentifier(); + + /** + * Topic Alias Maximum: the highest value accepted by the server as a + * Topic Alias. (MQTT 5.0) + * + * @return the topic alias maximum, or {@code null} if not present (meaning 0, i.e. no aliases) + */ + @Nullable + Integer topicAliasMaximum(); + + /** + * Human-readable reason string for the result of the connection attempt. (MQTT 5.0) + * + * @return the reason string, or {@code null} if not present + */ + @Nullable + String reasonString(); + + /** + * User Properties returned by the server in the CONNACK. (MQTT 5.0) + *

+ * Note: MQTT 5.0 allows duplicate keys; duplicate values are silently + * collapsed to the last value when using this {@code Map} representation. + * + * @return key-value user properties, or {@code null} if not present + */ + @Nullable + Map userProperties(); + + /** + * Keep Alive interval (in seconds) assigned by the server. (MQTT 5.0) + *

+ * If present, the client MUST use this value instead of the one it sent. + * + * @return the server keep alive, or {@code null} if the client-requested value should be used + */ + @Nullable + Integer serverKeepAlive(); + + /** + * Response Information used to construct the Response Topic. (MQTT 5.0) + *

+ * Only returned if the client set Request Response Information = 1 in CONNECT. + * + * @return the response information string, or {@code null} if not present + */ + @Nullable + String responseInformation(); + + /** + * Server Reference: another server the client should use to reconnect. (MQTT 5.0) + *

+ * Present when the server wants the client to use a different server. + * + * @return the server reference, or {@code null} if not present + */ + @Nullable + String serverReference(); + + /** + * Authentication Method used during enhanced authentication. (MQTT 5.0) + * + * @return the authentication method name, or {@code null} if not present + */ + @Nullable + String authenticationMethod(); + + /** + * Authentication Data used during enhanced authentication. (MQTT 5.0) + * + * @return the authentication data, or {@code null} if not present + */ + @Nullable + Buffer authenticationData(); } diff --git a/src/main/java/io/vertx/mqtt/messages/MqttSubAckMessage.java b/src/main/java/io/vertx/mqtt/messages/MqttSubAckMessage.java index d3b87757..c4c890f8 100644 --- a/src/main/java/io/vertx/mqtt/messages/MqttSubAckMessage.java +++ b/src/main/java/io/vertx/mqtt/messages/MqttSubAckMessage.java @@ -20,6 +20,7 @@ import io.vertx.codegen.annotations.GenIgnore; import io.vertx.codegen.annotations.VertxGen; import io.vertx.mqtt.messages.impl.MqttSubAckMessageImpl; +import io.netty.handler.codec.mqtt.MqttProperties; import java.util.List; @@ -38,7 +39,20 @@ public interface MqttSubAckMessage extends MqttMessage { */ @GenIgnore static MqttSubAckMessage create(int messageId, List grantedQosLevels) { - return new MqttSubAckMessageImpl(messageId, grantedQosLevels); + return new MqttSubAckMessageImpl(messageId, grantedQosLevels, MqttProperties.NO_PROPERTIES); + } + + /** + * Create a concrete instance of a Vert.x suback message + * + * @param messageId message identifier + * @param grantedQosLevels list of granted QoS levels + * @param properties MQTT properties + * @return + */ + @GenIgnore + static MqttSubAckMessage create(int messageId, List grantedQosLevels, MqttProperties properties) { + return new MqttSubAckMessageImpl(messageId, grantedQosLevels, properties); } /** @@ -46,4 +60,11 @@ static MqttSubAckMessage create(int messageId, List grantedQosLevels) { */ @CacheReturn List grantedQoSLevels(); + + /** + * @return MQTT properties + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @CacheReturn + MqttProperties properties(); } diff --git a/src/main/java/io/vertx/mqtt/messages/MqttUnsubAckMessage.java b/src/main/java/io/vertx/mqtt/messages/MqttUnsubAckMessage.java new file mode 100644 index 00000000..3ade53a6 --- /dev/null +++ b/src/main/java/io/vertx/mqtt/messages/MqttUnsubAckMessage.java @@ -0,0 +1,54 @@ +package io.vertx.mqtt.messages; + +import io.vertx.codegen.annotations.CacheReturn; +import io.vertx.codegen.annotations.GenIgnore; +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.mqtt.messages.impl.MqttUnsubAckMessageImpl; +import io.netty.handler.codec.mqtt.MqttProperties; + +import java.util.List; + +/** + * Represents an MQTT UNSUBACK message + */ +@VertxGen +public interface MqttUnsubAckMessage extends MqttMessage { + + /** + * Create a concrete instance of a Vert.x unsuback message + * + * @param messageId message identifier + * @param reasonCodes list of reason codes + * @return + */ + @GenIgnore + static MqttUnsubAckMessage create(int messageId, List reasonCodes) { + return new MqttUnsubAckMessageImpl(messageId, reasonCodes, MqttProperties.NO_PROPERTIES); + } + + /** + * Create a concrete instance of a Vert.x unsuback message + * + * @param messageId message identifier + * @param reasonCodes list of reason codes + * @param properties MQTT properties + * @return + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + static MqttUnsubAckMessage create(int messageId, List reasonCodes, MqttProperties properties) { + return new MqttUnsubAckMessageImpl(messageId, reasonCodes, properties); + } + + /** + * @return list of reason codes + */ + @CacheReturn + List reasonCodes(); + + /** + * @return MQTT properties + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @CacheReturn + MqttProperties properties(); +} diff --git a/src/main/java/io/vertx/mqtt/messages/codes/MqttAuthenticateReasonCode.java b/src/main/java/io/vertx/mqtt/messages/codes/MqttAuthenticateReasonCode.java new file mode 100644 index 00000000..741047ed --- /dev/null +++ b/src/main/java/io/vertx/mqtt/messages/codes/MqttAuthenticateReasonCode.java @@ -0,0 +1,30 @@ +package io.vertx.mqtt.messages.codes; + +public enum MqttAuthenticateReasonCode implements MqttReasonCode { + + SUCCESS((byte) 0x0), + + CONTINUE_AUTHENTICATION((byte) 0x18), + + RE_AUTHENTICATE((byte) 0x19); + + MqttAuthenticateReasonCode(byte byteValue) { + this.byteValue = byteValue; + } + + private final byte byteValue; + + @Override + public byte value() { + return byteValue; + } + + public static MqttAuthenticateReasonCode valueOf(byte b) { + for (MqttAuthenticateReasonCode code : MqttAuthenticateReasonCode.values()) { + if (code.byteValue == b) { + return code; + } + } + throw new IllegalArgumentException("unknown AUTHENTICATE reason code: " + b); + } +} diff --git a/src/main/java/io/vertx/mqtt/messages/impl/MqttAuthenticationExchangeMessageImpl.java b/src/main/java/io/vertx/mqtt/messages/impl/MqttAuthenticationExchangeMessageImpl.java new file mode 100644 index 00000000..7ccd8b54 --- /dev/null +++ b/src/main/java/io/vertx/mqtt/messages/impl/MqttAuthenticationExchangeMessageImpl.java @@ -0,0 +1,47 @@ +package io.vertx.mqtt.messages.impl; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.vertx.core.buffer.Buffer; +import io.vertx.mqtt.messages.MqttAuthenticationExchangeMessage; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; + +/** + * Represents an MQTT AUTH message + */ +public class MqttAuthenticationExchangeMessageImpl implements MqttAuthenticationExchangeMessage { + + private final MqttAuthenticateReasonCode reasonCode; + + private final MqttProperties properties; + + public MqttAuthenticationExchangeMessageImpl(MqttAuthenticateReasonCode reasonCode, MqttProperties properties) { + this.reasonCode = reasonCode; + this.properties = properties; + } + + @Override + public MqttAuthenticateReasonCode reasonCode() { + return reasonCode; + } + + @Override + @SuppressWarnings("rawtypes") + public String authenticationMethod() { + MqttProperties.MqttProperty prop = + properties.getProperty(MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value()); + return prop == null ? null : ((MqttProperties.StringProperty) prop).value(); + } + + @Override + @SuppressWarnings("rawtypes") + public Buffer authenticationData() { + MqttProperties.MqttProperty prop = + properties.getProperty(MqttProperties.MqttPropertyType.AUTHENTICATION_DATA.value()); + return prop == null ? null : Buffer.buffer(((MqttProperties.BinaryProperty) prop).value()); + } + + @Override + public MqttProperties properties() { + return properties; + } +} diff --git a/src/main/java/io/vertx/mqtt/messages/impl/MqttConnAckMessageImpl.java b/src/main/java/io/vertx/mqtt/messages/impl/MqttConnAckMessageImpl.java index 92ef9f6b..1bcb6a05 100644 --- a/src/main/java/io/vertx/mqtt/messages/impl/MqttConnAckMessageImpl.java +++ b/src/main/java/io/vertx/mqtt/messages/impl/MqttConnAckMessageImpl.java @@ -18,8 +18,14 @@ import io.netty.handler.codec.mqtt.MqttConnectReturnCode; import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttProperties.MqttPropertyType; +import io.vertx.core.buffer.Buffer; import io.vertx.mqtt.messages.MqttConnAckMessage; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + /** * Represents an MQTT CONNACK message */ @@ -49,18 +55,164 @@ public MqttConnAckMessageImpl(MqttConnectReturnCode code, boolean isSessionPrese public MqttConnAckMessageImpl(MqttConnectReturnCode code, boolean isSessionPresent, MqttProperties properties) { this.code = code; this.isSessionPresent = isSessionPresent; - this.properties = properties; + this.properties = properties != null ? properties : MqttProperties.NO_PROPERTIES; } + // ------------------------------------------------------------------------- + // MQTT 3.1.1 + // ------------------------------------------------------------------------- + + @Override public MqttConnectReturnCode code() { return this.code; } + @Override public boolean isSessionPresent() { return this.isSessionPresent; } + @Override public MqttProperties properties() { return this.properties; } + + // ------------------------------------------------------------------------- + // MQTT 5.0 typed accessors + // ------------------------------------------------------------------------- + + @Override + public Long sessionExpiryInterval() { + MqttProperties.MqttProperty prop = properties.getProperty(MqttPropertyType.SESSION_EXPIRY_INTERVAL.value()); + if (prop instanceof MqttProperties.IntegerProperty) { + // Treat as unsigned 32-bit + return ((MqttProperties.IntegerProperty) prop).value().longValue() & 0xFFFFFFFFL; + } + return null; + } + + @Override + public Integer receiveMaximum() { + return intProp(MqttPropertyType.RECEIVE_MAXIMUM); + } + + @Override + public Integer maximumQos() { + return intProp(MqttPropertyType.MAXIMUM_QOS); + } + + @Override + public Boolean retainAvailable() { + Integer v = intProp(MqttPropertyType.RETAIN_AVAILABLE); + return v != null ? v != 0 : null; + } + + @Override + public Boolean subscriptionIdentifierAvailable() { + Integer v = intProp(MqttPropertyType.SUBSCRIPTION_IDENTIFIER_AVAILABLE); + return v != null ? v != 0 : null; + } + + @Override + public Boolean wildcardSubscriptionAvailable() { + Integer v = intProp(MqttPropertyType.WILDCARD_SUBSCRIPTION_AVAILABLE); + return v != null ? v != 0 : null; + } + + @Override + public Boolean sharedSubscriptionAvailable() { + Integer v = intProp(MqttPropertyType.SHARED_SUBSCRIPTION_AVAILABLE); + return v != null ? v != 0 : null; + } + + @Override + public Long maximumPacketSize() { + MqttProperties.MqttProperty prop = properties.getProperty(MqttPropertyType.MAXIMUM_PACKET_SIZE.value()); + if (prop instanceof MqttProperties.IntegerProperty) { + return ((MqttProperties.IntegerProperty) prop).value().longValue() & 0xFFFFFFFFL; + } + return null; + } + + @Override + public String assignedClientIdentifier() { + return stringProp(MqttPropertyType.ASSIGNED_CLIENT_IDENTIFIER); + } + + @Override + public Integer topicAliasMaximum() { + return intProp(MqttPropertyType.TOPIC_ALIAS_MAXIMUM); + } + + @Override + public String reasonString() { + return stringProp(MqttPropertyType.REASON_STRING); + } + + @Override + public Map userProperties() { + MqttProperties.MqttProperty raw = properties.getProperty(MqttPropertyType.USER_PROPERTY.value()); + if (!(raw instanceof MqttProperties.UserProperties)) { + return null; + } + List pairs = ((MqttProperties.UserProperties) raw).value(); + if (pairs == null || pairs.isEmpty()) { + return null; + } + Map result = new LinkedHashMap<>(); + for (MqttProperties.StringPair pair : pairs) { + result.put(pair.key, pair.value); + } + return result; + } + + @Override + public Integer serverKeepAlive() { + return intProp(MqttPropertyType.SERVER_KEEP_ALIVE); + } + + @Override + public String responseInformation() { + return stringProp(MqttPropertyType.RESPONSE_INFORMATION); + } + + @Override + public String serverReference() { + return stringProp(MqttPropertyType.SERVER_REFERENCE); + } + + @Override + public String authenticationMethod() { + return stringProp(MqttPropertyType.AUTHENTICATION_METHOD); + } + + @Override + public Buffer authenticationData() { + MqttProperties.MqttProperty prop = properties.getProperty(MqttPropertyType.AUTHENTICATION_DATA.value()); + if (prop instanceof MqttProperties.BinaryProperty) { + byte[] bytes = ((MqttProperties.BinaryProperty) prop).value(); + return bytes != null ? Buffer.buffer(bytes) : null; + } + return null; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private Integer intProp(MqttPropertyType type) { + MqttProperties.MqttProperty prop = properties.getProperty(type.value()); + if (prop instanceof MqttProperties.IntegerProperty) { + return ((MqttProperties.IntegerProperty) prop).value(); + } + return null; + } + + private String stringProp(MqttPropertyType type) { + MqttProperties.MqttProperty prop = properties.getProperty(type.value()); + if (prop instanceof MqttProperties.StringProperty) { + return ((MqttProperties.StringProperty) prop).value(); + } + return null; + } } diff --git a/src/main/java/io/vertx/mqtt/messages/impl/MqttSubAckMessageImpl.java b/src/main/java/io/vertx/mqtt/messages/impl/MqttSubAckMessageImpl.java index c7c8f48a..5008094f 100644 --- a/src/main/java/io/vertx/mqtt/messages/impl/MqttSubAckMessageImpl.java +++ b/src/main/java/io/vertx/mqtt/messages/impl/MqttSubAckMessageImpl.java @@ -17,6 +17,7 @@ package io.vertx.mqtt.messages.impl; import io.vertx.mqtt.messages.MqttSubAckMessage; +import io.netty.handler.codec.mqtt.MqttProperties; import java.util.List; @@ -27,6 +28,7 @@ public class MqttSubAckMessageImpl implements MqttSubAckMessage { private final int messageId; private final List grantedQoSLevels; + private final MqttProperties properties; /** * Constructor @@ -35,8 +37,20 @@ public class MqttSubAckMessageImpl implements MqttSubAckMessage { * @param grantedQoSLevels list of granted QoS levels */ public MqttSubAckMessageImpl(int messageId, List grantedQoSLevels) { + this(messageId, grantedQoSLevels, MqttProperties.NO_PROPERTIES); + } + + /** + * Constructor + * + * @param messageId message identifier + * @param grantedQoSLevels list of granted QoS levels + * @param properties MQTT properties + */ + public MqttSubAckMessageImpl(int messageId, List grantedQoSLevels, MqttProperties properties) { this.messageId = messageId; this.grantedQoSLevels = grantedQoSLevels; + this.properties = properties; } public int messageId() { @@ -46,4 +60,8 @@ public int messageId() { public List grantedQoSLevels() { return grantedQoSLevels; } + + public MqttProperties properties() { + return properties; + } } diff --git a/src/main/java/io/vertx/mqtt/messages/impl/MqttUnsubAckMessageImpl.java b/src/main/java/io/vertx/mqtt/messages/impl/MqttUnsubAckMessageImpl.java new file mode 100644 index 00000000..26a1618c --- /dev/null +++ b/src/main/java/io/vertx/mqtt/messages/impl/MqttUnsubAckMessageImpl.java @@ -0,0 +1,43 @@ +package io.vertx.mqtt.messages.impl; + +import io.vertx.mqtt.messages.MqttUnsubAckMessage; +import io.netty.handler.codec.mqtt.MqttProperties; + +import java.util.List; + +/** + * Represents an MQTT UNSUBACK message + */ +public class MqttUnsubAckMessageImpl implements MqttUnsubAckMessage { + + private final int messageId; + private final List reasonCodes; + private final MqttProperties properties; + + /** + * Constructor for MqttUnsubAckMessageImpl + * @param messageId the message identifier + * @param reasonCodes the list of reason codes for the UNSUBACK message + * @param properties the MQTT properties associated with the UNSUBACK message + */ + public MqttUnsubAckMessageImpl(int messageId, List reasonCodes, MqttProperties properties) { + this.messageId = messageId; + this.reasonCodes = reasonCodes; + this.properties = properties; + } + + @Override + public int messageId() { + return this.messageId; + } + + @Override + public List reasonCodes() { + return this.reasonCodes; + } + + @Override + public MqttProperties properties() { + return this.properties; + } +} diff --git a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientBaseIT.java b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientBaseIT.java new file mode 100644 index 00000000..487b6eec --- /dev/null +++ b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientBaseIT.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.it; + +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClientOptions; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Base class for MQTT 5.0 integration tests running against a real Mosquitto 2.x broker. + * The broker is started via Testcontainers using the same {@code mosquitto.conf} used by + * the existing integration tests ({@code allow_anonymous true}, port 1883). + */ +@RunWith(VertxUnitRunner.class) +public abstract class Mqtt5ClientBaseIT { + + public GenericContainer mosquitto = new GenericContainer<>(DockerImageName.parse("eclipse-mosquitto:2.0.12")) + .withExposedPorts(1883) + .withClasspathResourceMapping("it/mosquitto.conf", "/mosquitto/config/mosquitto.conf", BindMode.READ_ONLY) + .waitingFor(Wait.forLogMessage(".*mosquitto .* running.*", 1)); + + protected int port; + protected String host; + + @Before + public void setUp() { + mosquitto.start(); + port = mosquitto.getMappedPort(1883); + host = mosquitto.getHost(); + } + + protected MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + opts.setAutoGeneratedClientId(true); + return opts; + } +} diff --git a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java new file mode 100644 index 00000000..754eafad --- /dev/null +++ b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java @@ -0,0 +1,110 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.it; + +import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.TestContext; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.messages.MqttConnAckMessage; +import org.junit.After; +import org.junit.Test; + +/** + * Integration tests for MQTT 5.0 CONNECT / CONNACK against a real Mosquitto 2.x broker. + */ +public class Mqtt5ClientConnectIT extends Mqtt5ClientBaseIT { + + private Vertx vertx; + + @After + public void tearDown(TestContext ctx) { + if (vertx != null) { + vertx.close().onComplete(ctx.asyncAssertSuccess()); + } + } + + /** + * Connect with MQTT 5.0 protocol version: broker must accept the connection and + * return CONNECTION_ACCEPTED. + */ + @Test + public void connectWithMqtt5Version(TestContext ctx) { + vertx = Vertx.vertx(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertEquals(MqttConnectReturnCode.CONNECTION_ACCEPTED, ack.code()); + client.disconnect().onComplete(ctx.asyncAssertSuccess()); + })); + } + + /** + * Connect with MQTT 5.0 and SESSION_EXPIRY_INTERVAL: broker must accept and + * return a valid CONNACK (Mosquitto echoes or ignores this property, but does not reject it). + */ + @Test + public void connectWithSessionExpiryInterval(TestContext ctx) { + vertx = Vertx.vertx(); + MqttClientOptions opts = v5Options(); + opts.setSessionExpireInterval(60L); + + MqttClient client = MqttClient.create(vertx, opts); + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertEquals(MqttConnectReturnCode.CONNECTION_ACCEPTED, ack.code()); + client.disconnect().onComplete(ctx.asyncAssertSuccess()); + })); + } + + /** + * Connect and verify that Mosquitto 2.x provides server-specific CONNACK properties. + * At minimum, Mosquitto 2.x sends RECEIVE_MAXIMUM and optionally TOPIC_ALIAS_MAXIMUM. + */ + @Test + public void connAckContainsServerProperties(TestContext ctx) { + vertx = Vertx.vertx(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + // Mosquitto 2.x always includes RECEIVE_MAXIMUM in v5 CONNACK + ctx.assertNotNull(ack.receiveMaximum(), "CONNACK must contain RECEIVE_MAXIMUM"); + client.disconnect().onComplete(ctx.asyncAssertSuccess()); + })); + } + + /** + * Connect and disconnect with MQTT 5.0 DISCONNECT reason code NORMAL. + */ + @Test + public void connectAndDisconnectWithReasonCode(TestContext ctx) { + vertx = Vertx.vertx(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertEquals(MqttConnectReturnCode.CONNECTION_ACCEPTED, ack.code()); + client.disconnect( + io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode.NORMAL, + io.netty.handler.codec.mqtt.MqttProperties.NO_PROPERTIES) + .onComplete(ctx.asyncAssertSuccess()); + })); + } +} diff --git a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientPublishIT.java b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientPublishIT.java new file mode 100644 index 00000000..50baa2e2 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientPublishIT.java @@ -0,0 +1,135 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.it; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.mqtt.MqttClient; +import org.junit.After; +import org.junit.Test; + +/** + * Integration tests for MQTT 5.0 PUBLISH against a real Mosquitto 2.x broker. + */ +public class Mqtt5ClientPublishIT extends Mqtt5ClientBaseIT { + + private static final String TOPIC = "/mqtt5/it/publish"; + + private Vertx vertx; + + @After + public void tearDown(TestContext ctx) { + if (vertx != null) { + vertx.close().onComplete(ctx.asyncAssertSuccess()); + } + } + + /** + * Publish QoS 0 with MQTT 5.0: fire and forget, future completes after send. + */ + @Test + public void publishQos0(TestContext ctx) { + vertx = Vertx.vertx(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(TOPIC, Buffer.buffer("qos0-payload"), MqttQoS.AT_MOST_ONCE, false, false) + .onComplete(ctx.asyncAssertSuccess(id -> + client.disconnect().onComplete(ctx.asyncAssertSuccess()))))); + } + + /** + * Publish QoS 1 with MQTT 5.0: broker sends PUBACK, future completes after PUBACK. + */ + @Test + public void publishQos1(TestContext ctx) { + vertx = Vertx.vertx(); + Async published = ctx.async(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.publishCompletionHandler(id -> published.complete()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(TOPIC, Buffer.buffer("qos1-payload"), MqttQoS.AT_LEAST_ONCE, false, false))); + + published.awaitSuccess(10000); + } + + /** + * Publish QoS 2 with MQTT 5.0: full PUBREC/PUBREL/PUBCOMP handshake with broker. + */ + @Test + public void publishQos2(TestContext ctx) { + vertx = Vertx.vertx(); + Async published = ctx.async(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.publishCompletionHandler(id -> published.complete()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(TOPIC, Buffer.buffer("qos2-payload"), MqttQoS.EXACTLY_ONCE, false, false))); + + published.awaitSuccess(10000); + } + + /** + * Publish QoS 1 with MQTT 5.0 user properties: broker accepts the message. + */ + @Test + public void publishWithUserProperties(TestContext ctx) { + vertx = Vertx.vertx(); + Async published = ctx.async(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.publishCompletionHandler(id -> published.complete()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.UserProperties( + java.util.Arrays.asList(new MqttProperties.StringPair("source", "vertx-mqtt-test")))); + client.publish(TOPIC, Buffer.buffer("with-user-props"), MqttQoS.AT_LEAST_ONCE, false, false, props); + })); + + published.awaitSuccess(10000); + } + + /** + * Publish QoS 0 with CONTENT_TYPE property: broker accepts the message. + */ + @Test + public void publishWithContentType(TestContext ctx) { + vertx = Vertx.vertx(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.CONTENT_TYPE.value(), "application/json")); + client.publish(TOPIC, Buffer.buffer("{\"test\":true}"), MqttQoS.AT_MOST_ONCE, false, false, props) + .onComplete(ctx.asyncAssertSuccess(id -> + client.disconnect().onComplete(ctx.asyncAssertSuccess()))); + })); + } +} diff --git a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java new file mode 100644 index 00000000..c21eb003 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java @@ -0,0 +1,153 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.it; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttSubscriptionOption; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.messages.MqttSubAckMessage; +import org.junit.After; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +/** + * Integration tests for MQTT 5.0 SUBSCRIBE against a real Mosquitto 2.x broker. + */ +public class Mqtt5ClientSubscribeIT extends Mqtt5ClientBaseIT { + + private static final String TOPIC = "/mqtt5/it/subscribe"; + + private Vertx vertx; + + @After + public void tearDown(TestContext ctx) { + if (vertx != null) { + vertx.close().onComplete(ctx.asyncAssertSuccess()); + } + } + + /** + * Subscribe to a topic with QoS 0 using MQTT 5.0: SUBACK must contain a granted QoS. + */ + @Test + public void subscribeQos0(TestContext ctx) { + vertx = Vertx.vertx(); + Async subscribed = ctx.async(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.subscribeCompletionHandler((MqttSubAckMessage ack) -> { + ctx.assertFalse(ack.grantedQoSLevels().isEmpty()); + subscribed.complete(); + }); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> + client.subscribe(TOPIC, 0))); + + subscribed.awaitSuccess(10000); + } + + /** + * Subscribe to a topic with QoS 1 using MQTT 5.0: broker must grant QoS 1. + */ + @Test + public void subscribeQos1(TestContext ctx) { + vertx = Vertx.vertx(); + Async subscribed = ctx.async(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.subscribeCompletionHandler((MqttSubAckMessage ack) -> { + ctx.assertFalse(ack.grantedQoSLevels().isEmpty()); + // Mosquitto grants at least QoS 0; typically grants what was requested + subscribed.complete(); + }); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> + client.subscribe(TOPIC, 1))); + + subscribed.awaitSuccess(10000); + } + + /** + * Subscribe and receive a published message end-to-end with MQTT 5.0. + * Two clients: publisher (QoS 0) and subscriber (QoS 0). + */ + @Test + public void subscribeAndReceiveMessage(TestContext ctx) { + vertx = Vertx.vertx(); + Async received = ctx.async(); + String payload = "hello-mqtt5"; + + MqttClient subscriber = MqttClient.create(vertx, v5Options()); + MqttClient publisher = MqttClient.create(vertx, v5Options()); + + subscriber.publishHandler(msg -> { + ctx.assertEquals(payload, msg.payload().toString()); + received.complete(); + }); + + subscriber.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + subscriber.subscribe(TOPIC, 0) + .onComplete(ctx.asyncAssertSuccess(subId -> + publisher.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack2 -> + publisher.publish(TOPIC, Buffer.buffer(payload), MqttQoS.AT_MOST_ONCE, false, false))))); + })); + + received.awaitSuccess(10000); + } + + /** + * Subscribe with subscription options (No Local, Retain Handling) using MQTT 5.0. + * Broker must accept the subscription without error. + */ + @Test + public void subscribeWithSubscriptionOptions(TestContext ctx) { + vertx = Vertx.vertx(); + Async subscribed = ctx.async(); + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.subscribeCompletionHandler((MqttSubAckMessage ack) -> { + ctx.assertFalse(ack.grantedQoSLevels().isEmpty()); + subscribed.complete(); + }); + + client.connect(port, host) + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttSubscriptionOption option = new MqttSubscriptionOption( + MqttQoS.AT_MOST_ONCE, + false, + false, + MqttSubscriptionOption.RetainedHandlingPolicy.SEND_AT_SUBSCRIBE_IF_NOT_YET_EXISTS); + client.subscribe( + java.util.Arrays.asList(new MqttTopicSubscription(TOPIC, option)), + MqttProperties.NO_PROPERTIES); + })); + + subscribed.awaitSuccess(10000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientConnectTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientConnectTest.java new file mode 100644 index 00000000..7f513ece --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientConnectTest.java @@ -0,0 +1,445 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.Vertx; +import java.util.List; +import java.util.Map; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; + +/** + * Tests for the MQTT v5 CONNECT packet properties sent by the client. + * The embedded MqttServer inspects endpoint.connectProperties() to verify + * that each property is correctly encoded by MqttClientImpl. + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientConnectTest { + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + + /** + * Connecting with version=5 must result in the server seeing protocol level 5. + */ + @Test + public void connectWithVersion5(TestContext ctx) { + Async serverLatch = ctx.async(); + Async connected = ctx.async(); + + server.endpointHandler(endpoint -> { + ctx.assertEquals((int) MqttVersion.MQTT_5.protocolLevel(), endpoint.protocolVersion()); + ctx.assertEquals(MqttVersion.MQTT_5.protocolName(), endpoint.protocolName()); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(v -> connected.complete())); + }); + + serverLatch.awaitSuccess(5000); + connected.awaitSuccess(5000); + } + + /** + * setSessionExpireInterval must be encoded as SESSION_EXPIRY_INTERVAL in the CONNECT properties. + */ + @Test + public void connectWithSessionExpireInterval(TestContext ctx) { + Long expected = 300l; + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties.MqttProperty prop = endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.SESSION_EXPIRY_INTERVAL.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(expected, Integer.toUnsignedLong((Integer) prop.value())); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setSessionExpireInterval(expected); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * setSessionExpireInterval must be encoded as SESSION_EXPIRY_INTERVAL in the CONNECT properties. + */ + @Test + public void connectWithMaxSessionExpireInterval(TestContext ctx) { + Long expected = 4294967295l; + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties.MqttProperty prop = endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.SESSION_EXPIRY_INTERVAL.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(expected, Integer.toUnsignedLong((Integer) prop.value())); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setSessionExpireInterval(expected); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * setReceiveMaximum must be encoded as RECEIVE_MAXIMUM in the CONNECT properties. + */ + @Test + public void connectWithReceiveMaximum(TestContext ctx) { + Integer expected = 300; + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties.MqttProperty prop = endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.RECEIVE_MAXIMUM.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(expected.longValue(), Integer.toUnsignedLong((Integer) prop.value())); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setReceiveMaximum(expected); + options.setTopicAliasMaximum(null); // suppress default so we only see RECEIVE_MAXIMUM + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * setMaximumPacketSize must be encoded as MAXIMUM_PACKET_SIZE in the CONNECT properties. + */ + @Test + public void connectWithMaximumPacketSize(TestContext ctx) { + Long expected = 65535l; + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties.MqttProperty prop = endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.MAXIMUM_PACKET_SIZE.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(expected, Integer.toUnsignedLong((Integer) prop.value())); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setMaximumPacketSize(expected); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * setTopicAliasMaximum must be encoded as TOPIC_ALIAS_MAXIMUM in the CONNECT properties. + */ + @Test + public void connectWithTopicAliasMaximum(TestContext ctx) { + Integer expected = 5; + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties.MqttProperty prop = endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS_MAXIMUM.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(expected.longValue(), Integer.toUnsignedLong((Integer) prop.value())); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setTopicAliasMaximum(expected); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * All v5 CONNECT properties should be encoded together correctly. + */ + @Test + public void connectWithAllProperties(TestContext ctx) { + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties props = endpoint.connectProperties(); + ctx.assertNotNull(props.getProperty(MqttProperties.MqttPropertyType.SESSION_EXPIRY_INTERVAL.value())); + ctx.assertNotNull(props.getProperty(MqttProperties.MqttPropertyType.RECEIVE_MAXIMUM.value())); + ctx.assertNotNull(props.getProperty(MqttProperties.MqttPropertyType.MAXIMUM_PACKET_SIZE.value())); + ctx.assertNotNull(props.getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS_MAXIMUM.value())); + ctx.assertEquals(60l, Integer.toUnsignedLong((Integer) props.getProperty(MqttProperties.MqttPropertyType.SESSION_EXPIRY_INTERVAL.value()).value())); + ctx.assertEquals(20l, Integer.toUnsignedLong((Integer) props.getProperty(MqttProperties.MqttPropertyType.RECEIVE_MAXIMUM.value()).value())); + ctx.assertEquals(32768l, Integer.toUnsignedLong((Integer) props.getProperty(MqttProperties.MqttPropertyType.MAXIMUM_PACKET_SIZE.value()).value())); + ctx.assertEquals(3l, Integer.toUnsignedLong((Integer) props.getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS_MAXIMUM.value()).value())); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setSessionExpireInterval(60l); + options.setReceiveMaximum(20); + options.setMaximumPacketSize(32768l); + options.setTopicAliasMaximum(3); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * setAuthenticationMethod must be encoded as AUTHENTICATION_METHOD in the CONNECT properties. + */ + @Test + public void connectWithAuthenticationMethod(TestContext ctx) { + String expected = "SCRAM-SHA-256"; + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties.MqttProperty prop = endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(expected, prop.value()); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setAuthenticationMethod(expected); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * setAuthenticationMethod + setAuthenticationData must both appear in CONNECT properties. + */ + @Test + public void connectWithAuthenticationMethodAndData(TestContext ctx) { + String expectedMethod = "SCRAM-SHA-256"; + byte[] expectedData = new byte[]{0x01, 0x02, 0x03, 0x04}; + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties props = endpoint.connectProperties(); + MqttProperties.MqttProperty methodProp = props.getProperty(MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value()); + MqttProperties.MqttProperty dataProp = props.getProperty(MqttProperties.MqttPropertyType.AUTHENTICATION_DATA.value()); + ctx.assertNotNull(methodProp); + ctx.assertEquals(expectedMethod, methodProp.value()); + ctx.assertNotNull(dataProp); + ctx.assertEquals(Buffer.buffer(expectedData), Buffer.buffer((byte[]) dataProp.value())); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setAuthenticationMethod(expectedMethod); + options.setAuthenticationData(Buffer.buffer(expectedData)); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost"); + }); + + serverLatch.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + // CONNACK properties received by the client + // ----------------------------------------------------------------------- + + /** + * Client sets REQUEST_RESPONSE_INFORMATION=1 in CONNECT. + * Server replies with RESPONSE_INFORMATION in CONNACK. + * The connect Future result (MqttConnAckMessage) must expose the value + * via responseInformation(). + */ + @Test + public void connackResponseInformation(TestContext ctx) { + String expected = "responses/client-id-1"; + Async done = ctx.async(); + + server.endpointHandler(endpoint -> { + // Verify the client actually asked for response information + MqttProperties.MqttProperty reqProp = + endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.REQUEST_RESPONSE_INFORMATION.value()); + ctx.assertNotNull(reqProp); + ctx.assertEquals(1, reqProp.value()); + + // Include RESPONSE_INFORMATION in CONNACK + MqttProperties connAckProps = new MqttProperties(); + connAckProps.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.RESPONSE_INFORMATION.value(), expected)); + endpoint.accept(false, connAckProps); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setRequestResponseInformation(true); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertEquals(expected, ack.responseInformation()); + done.complete(); + })); + }); + + done.awaitSuccess(5000); + } + + /** + * When the client does NOT request response information (REQUEST_RESPONSE_INFORMATION absent + * or 0), the server MUST NOT include RESPONSE_INFORMATION in CONNACK. + * Verify responseInformation() returns null. + */ + @Test + public void connackResponseInformationAbsentWhenNotRequested(TestContext ctx) { + Async done = ctx.async(); + + server.endpointHandler(endpoint -> { + // Send CONNACK with no RESPONSE_INFORMATION (server respects the spec) + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + // requestResponseInformation is not set (defaults to null / false) + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertNull(ack.responseInformation()); + done.complete(); + })); + }); + + done.awaitSuccess(5000); + } + + /** + * User Properties set on MqttClientOptions must arrive on the server + * in the CONNECT packet's USER_PROPERTY list. + */ + @Test + public void connectWithUserProperties(TestContext ctx) { + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties.MqttProperty prop = endpoint.connectProperties().getProperty(MqttProperties.MqttPropertyType.USER_PROPERTY.value()); + ctx.assertNotNull(prop, "USER_PROPERTY must be present in CONNECT"); + ctx.assertTrue(prop instanceof MqttProperties.UserProperties, + "USER_PROPERTY must be an instance of UserProperties"); + List pairs = ((MqttProperties.UserProperties) prop).value(); + ctx.assertNotNull(pairs, "USER_PROPERTY value must not be null"); + ctx.assertFalse(pairs.isEmpty(), "USER_PROPERTY must have at least one pair"); + boolean found = pairs.stream().anyMatch(p -> "k1".equals(p.key) && "v1".equals(p.value)); + ctx.assertTrue(found, "USER_PROPERTY must contain k1=v1"); + endpoint.accept(false); + serverLatch.complete(); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + + MqttClient.create(vertx, options) + .connect(server.actualPort(), "localhost", null, java.util.Collections.singletonMap("k1", "v1")); + }); + + serverLatch.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientDisconnectTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientDisconnectTest.java new file mode 100644 index 00000000..9b8a88c6 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientDisconnectTest.java @@ -0,0 +1,211 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tests for the MQTT v5 DISCONNECT packet sent by the client with reason codes. + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientDisconnectTest { + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + + /** + * Plain disconnect (no reason code) should trigger the server's disconnectHandler. + */ + @Test + public void disconnectNormal(TestContext ctx) { + Async disconnected = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.disconnectHandler(v -> disconnected.complete()); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + + MqttClient client = MqttClient.create(vertx, options); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> client.disconnect(MqttDisconnectReasonCode.NORMAL, MqttProperties.NO_PROPERTIES) + .onComplete(ctx.asyncAssertSuccess()))); + }); + + disconnected.awaitSuccess(5000); + } + + /** + * Disconnect with a specific reason code — the client must be able to send it + * without error (the server-side handler fires which proves the packet reached it). + */ + @Test + public void disconnectWithReasonCode(TestContext ctx) { + Async disconnected = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.disconnectHandler(v -> disconnected.complete()); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + + MqttClient client = MqttClient.create(vertx, options); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> client.disconnect(MqttDisconnectReasonCode.SESSION_TAKEN_OVER, MqttProperties.NO_PROPERTIES) + .onComplete(ctx.asyncAssertSuccess()))); + }); + + disconnected.awaitSuccess(5000); + } + + /** + * After a v5 disconnect the client must be in a disconnected state. + */ + @Test + public void disconnectAndReconnect(TestContext ctx) { + Async reconnected = ctx.async(); + + server.endpointHandler(endpoint -> endpoint.accept(false)); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + + MqttClient client = MqttClient.create(vertx, options); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack1 -> client.disconnect(MqttDisconnectReasonCode.NORMAL, MqttProperties.NO_PROPERTIES) + .onComplete(ctx.asyncAssertSuccess(v -> { + ctx.assertFalse(client.isConnected()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack2 -> { + ctx.assertTrue(client.isConnected()); + reconnected.complete(); + })); + })))); + }); + + reconnected.awaitSuccess(8000); + } + + /** + * Server sends DISCONNECT with SESSION_TAKEN_OVER — the client's disconnectMessageHandler + * must fire with the correct reason code. + */ + @Test + public void serverDisconnectMessageHandlerReceivesReasonCode(TestContext ctx) { + Async async = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false); + // send server-initiated DISCONNECT after a short delay + vertx.setTimer(100, t -> + endpoint.disconnect(MqttDisconnectReasonCode.SESSION_TAKEN_OVER, MqttProperties.NO_PROPERTIES)); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + + MqttClient client = MqttClient.create(vertx, options); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + client.disconnectMessageHandler(msg -> { + ctx.assertEquals(MqttDisconnectReasonCode.SESSION_TAKEN_OVER, msg.code()); + async.complete(); + }); + })); + }); + + async.awaitSuccess(5000); + } + + /** + * After a server-initiated DISCONNECT the closeHandler must also fire. + */ + @Test + public void serverDisconnectAlsoFiresCloseHandler(TestContext ctx) { + Async disconnectHandlerFired = ctx.async(); + Async closeHandlerFired = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false); + vertx.setTimer(100, t -> + endpoint.disconnect(MqttDisconnectReasonCode.SERVER_BUSY, MqttProperties.NO_PROPERTIES)); + }); + + startServer(ctx, () -> { + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + + MqttClient client = MqttClient.create(vertx, options); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + client.disconnectMessageHandler(msg -> { + ctx.assertEquals(MqttDisconnectReasonCode.SERVER_BUSY, msg.code()); + disconnectHandlerFired.complete(); + }); + client.closeHandler(v -> closeHandlerFired.complete()); + })); + }); + + disconnectHandlerFired.awaitSuccess(5000); + closeHandlerFired.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java new file mode 100644 index 00000000..88340af9 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java @@ -0,0 +1,242 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttException; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.codes.MqttPubAckReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; + +/** + * Tests for MQTT 5.0 flow control enforcement: + * - Receive Maximum: client must not exceed the server's in-flight QoS 1/2 limit. + * - Maximum QoS: client must reject publishes whose QoS exceeds server's Maximum QoS. + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientFlowControlTest { + + private static final String TOPIC = "/mqtt5/flow/test"; + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + // Receive Maximum enforcement + // ----------------------------------------------------------------------- + + /** + * Server advertises RECEIVE_MAXIMUM=2 in CONNACK. + * Client sends 2 QoS 1 messages without waiting for PUBACK: both succeed. + * A third QoS 1 message must fail immediately with MQTT_INFLIGHT_QUEUE_FULL. + */ + @Test + public void receiveMaximumRespected(TestContext ctx) { + Async thirdFailed = ctx.async(); + + server.endpointHandler(endpoint -> { + // Advertise RECEIVE_MAXIMUM=2 — do NOT send PUBACK automatically so + // the client's in-flight counter stays at 2 after the first two publishes. + endpoint.accept(false, buildConnAckPropsWithReceiveMaximum(2)); + // intentionally no publishHandler → messages pile up unacknowledged + }); + + startServer(ctx, () -> { + MqttClientOptions opts = v5Options(); + opts.setAutoAck(false); + // raise local inflight limit so it doesn't interfere + opts.setMaxInflightQueue(100); + MqttClient client = MqttClient.create(vertx, opts); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + // First two publishes should succeed + Future f1 = client.publish(TOPIC, Buffer.buffer("msg1"), MqttQoS.AT_LEAST_ONCE, false, false); + Future f2 = client.publish(TOPIC, Buffer.buffer("msg2"), MqttQoS.AT_LEAST_ONCE, false, false); + + Future.all(f1, f2).onComplete(ctx.asyncAssertSuccess(v -> { + // Third publish must fail: server Receive Maximum exceeded + client.publish(TOPIC, Buffer.buffer("msg3"), MqttQoS.AT_LEAST_ONCE, false, false) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof MqttException); + ctx.assertEquals(MqttException.MQTT_INFLIGHT_QUEUE_FULL, + ((MqttException) err).code()); + thirdFailed.complete(); + })); + })); + })); + }); + + thirdFailed.awaitSuccess(5000); + } + + /** + * Server advertises RECEIVE_MAXIMUM=2 but QoS 0 messages are NOT counted against it. + * Sending 3 QoS 0 messages after 2 unacknowledged QoS 1 messages must all succeed. + */ + @Test + public void receiveMaximumDoesNotApplyToQos0(TestContext ctx) { + Async allSent = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false, buildConnAckPropsWithReceiveMaximum(2)); + // intentionally no publishHandler → QoS1 messages stay unacknowledged + }); + + startServer(ctx, () -> { + MqttClientOptions opts = v5Options(); + opts.setAutoAck(false); + opts.setMaxInflightQueue(100); + MqttClient client = MqttClient.create(vertx, opts); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + // Saturate server receive window with 2 QoS 1 messages + Future f1 = client.publish(TOPIC, Buffer.buffer("q1-1"), MqttQoS.AT_LEAST_ONCE, false, false); + Future f2 = client.publish(TOPIC, Buffer.buffer("q1-2"), MqttQoS.AT_LEAST_ONCE, false, false); + + Future.all(f1, f2).onComplete(ctx.asyncAssertSuccess(v -> { + // QoS 0 publishes must still succeed regardless of receive maximum + List> qos0 = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + qos0.add(client.publish(TOPIC, Buffer.buffer("q0-" + i), MqttQoS.AT_MOST_ONCE, false, false)); + } + Future.all(qos0).onComplete(ctx.asyncAssertSuccess(v2 -> allSent.complete())); + })); + })); + }); + + allSent.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + // Maximum QoS enforcement + // ----------------------------------------------------------------------- + + /** + * Server advertises MAXIMUM_QOS=1 in CONNACK. + * Client attempts to publish with QoS 2: must fail immediately. + */ + @Test + public void maxQosEnforced(TestContext ctx) { + Async failedLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false, buildConnAckPropsWithMaxQos(1)); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + client.publish(TOPIC, Buffer.buffer("qos2-rejected"), MqttQoS.EXACTLY_ONCE, false, false) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof MqttException); + ctx.assertEquals(MqttException.MQTT_QOS_UNSUPPORTED, ((MqttException) err).code()); + failedLatch.complete(); + })); + })); + }); + + failedLatch.awaitSuccess(5000); + } + + /** + * Server advertises MAXIMUM_QOS=1 in CONNACK. + * Client publishes with QoS 0 and QoS 1: both must succeed. + */ + @Test + public void maxQos1AllowsLowerQos(TestContext ctx) { + Async bothSent = ctx.async(); + + server.endpointHandler(endpoint -> { + // When client publishes QoS1, server must send PUBACK + endpoint.publishHandler(msg -> endpoint.publishAcknowledge(msg.messageId(), MqttPubAckReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES)); + endpoint.accept(false, buildConnAckPropsWithMaxQos(1)); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + Future f0 = client.publish(TOPIC, Buffer.buffer("qos0"), MqttQoS.AT_MOST_ONCE, false, false); + Future f1 = client.publish(TOPIC, Buffer.buffer("qos1"), MqttQoS.AT_LEAST_ONCE, false, false); + Future.all(f0, f1).onComplete(ctx.asyncAssertSuccess(v -> bothSent.complete())); + })); + }); + + bothSent.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private MqttProperties buildConnAckPropsWithReceiveMaximum(int receiveMaximum) { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.RECEIVE_MAXIMUM.value(), receiveMaximum)); + return props; + } + + private MqttProperties buildConnAckPropsWithMaxQos(int maxQos) { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.MAXIMUM_QOS.value(), maxQos)); + return props; + } + + private MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + return opts; + } + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientPublishTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientPublishTest.java new file mode 100644 index 00000000..3f10e6da --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientPublishTest.java @@ -0,0 +1,699 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.codes.MqttPubAckReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubCompReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubRecReasonCode; +import io.vertx.mqtt.messages.codes.MqttPubRelReasonCode; +import io.vertx.mqtt.messages.codes.MqttSubAckReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import io.vertx.mqtt.MqttException; +import io.vertx.mqtt.messages.codes.MqttSubAckReasonCode; +import java.util.List; +import java.util.Map; + +/** + * Tests for the MQTT v5 publish flow with reason codes driven by + * the new {@code publishAcknowledge / publishReceived / publishRelease / publishComplete} + * APIs on both the server (MqttEndpoint) and client (MqttClient). + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientPublishTest { + + private static final String MQTT_TOPIC = "/mqtt5/pub/test"; + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> + vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + // QoS 1 — client publishes, server acknowledges + // ----------------------------------------------------------------------- + + /** + * Client publishes QoS 1; server sends PUBACK with SUCCESS. + * Client's publishCompletionHandler must fire (packetId received == packetId sent). + */ + @Test + public void publishQos1Success(TestContext ctx) { + Async published = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> + endpoint.publishAcknowledge(msg.messageId(), + MqttPubAckReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES)); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.publishCompletionHandler(id -> published.complete()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(MQTT_TOPIC, Buffer.buffer("hello-qos1"), MqttQoS.AT_LEAST_ONCE, false, false))); + }); + + published.awaitSuccess(5000); + } + + /** + * Server responds to a QoS 1 PUBLISH with PUBACK NOT_AUTHORIZED. + * The client's publishCompletionHandler must still fire (the ack is received), + * proving the reason code path is exercised without crashing the client. + */ + @Test + public void publishQos1WithErrorReasonCode(TestContext ctx) { + Async published = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> + endpoint.publishAcknowledge(msg.messageId(), + MqttPubAckReasonCode.NOT_AUTHORIZED, MqttProperties.NO_PROPERTIES)); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + // The publishCompletionHandler fires for QoS 1 on PUBACK receipt regardless of code + client.publishCompletionHandler(id -> published.complete()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(MQTT_TOPIC, Buffer.buffer("hello-nauth"), MqttQoS.AT_LEAST_ONCE, false, false))); + }); + + published.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + // QoS 2 — server drives the full PUBREC → PUBREL → PUBCOMP handshake + // ----------------------------------------------------------------------- + + /** + * Full QoS 2 handshake with SUCCESS reason codes. + * Server: receives PUBLISH → sends PUBREC(SUCCESS) → receives PUBREL → sends PUBCOMP(SUCCESS). + * Client: publishCompletionHandler fires after PUBCOMP. + * The client auto-sends PUBREL after PUBREC (autoAck=true by default). + */ + @Test + public void publishQos2Success(TestContext ctx) { + Async published = ctx.async(); + + server.endpointHandler(endpoint -> { + // PUBLISH received → send PUBREC with SUCCESS + endpoint.publishHandler(msg -> + endpoint.publishReceived(msg.messageId(), + MqttPubRecReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES)); + + // PUBREL received → send PUBCOMP with SUCCESS + endpoint.publishReleaseHandler(id -> + endpoint.publishComplete(id, + MqttPubCompReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES)); + + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + // autoAck=true: client auto-sends PUBREL when it receives PUBREC + MqttClient client = MqttClient.create(vertx, options); + + // publishCompletionHandler fires on PUBCOMP receipt + client.publishCompletionHandler(id -> published.complete()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(MQTT_TOPIC, Buffer.buffer("hello-qos2"), MqttQoS.EXACTLY_ONCE, false, false))); + }); + + published.awaitSuccess(5000); + } + + /** + * QoS 2 handshake where server sends PUBREC with an error code (QUOTA_EXCEEDED). + * Client receives the PUBREC; publishHandler fires on the client as the QoS2 message + * was "received". Client then sends PUBREL with a reason code. + */ + @Test + public void publishQos2WithPubRecErrorCode(TestContext ctx) { + Async pubRelReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> + endpoint.publishReceived(msg.messageId(), + MqttPubRecReasonCode.QUOTA_EXCEEDED, MqttProperties.NO_PROPERTIES)); + + // Even with error PUBREC, client should still send PUBREL + endpoint.publishReleaseHandler(id -> { + pubRelReceived.complete(); + endpoint.publishComplete(id, MqttPubCompReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES); + }); + + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(MQTT_TOPIC, Buffer.buffer("hello-quota"), MqttQoS.EXACTLY_ONCE, false, false))); + }); + + pubRelReceived.awaitSuccess(5000); + } + + /** + * Client sends PUBREL explicitly with a specific reason code (PACKET_IDENTIFIER_NOT_FOUND). + * autoAck=false so we control PUBREL manually via publishHandler callback. + */ + @Test + public void publishQos2ClientSendsPubRelWithReasonCode(TestContext ctx) { + Async pubRelReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> + endpoint.publishReceived(msg.messageId(), + MqttPubRecReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES)); + + endpoint.publishReleaseHandler(id -> { + // PUBREL was received by the server + pubRelReceived.complete(); + endpoint.publishComplete(id, MqttPubCompReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES); + }); + + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + options.setAutoAck(false); // we drive PUBREL manually + + MqttClient client = MqttClient.create(vertx, options); + + // On PUBREC (which arrives as PUBCOMP-phase in QoS2), send PUBREL manually with reason code + // When autoAck=false, publishHandler is called when the PUBLISH message is fully delivered + // Note: for client-as-publisher QoS2, the client uses publishCompletionHandler flow. + // After PUBREC is received, the client needs to send PUBREL. + // With autoAck=false, we use publishComplete which maps to client-side PUBREL sending. + client.publishCompletionHandler(id -> + client.publishRelease(id, MqttPubRelReasonCode.PACKET_IDENTIFIER_NOT_FOUND, MqttProperties.NO_PROPERTIES)); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(MQTT_TOPIC, Buffer.buffer("hello-pubrel-code"), MqttQoS.EXACTLY_ONCE, false, false))); + }); + + pubRelReceived.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + // Client sends PUBACK (QoS 1) for server-initiated publish + // ----------------------------------------------------------------------- + + /** + * Client acts as a subscriber; server publishes QoS 1; client sends PUBACK with SUCCESS + * using the explicit publishAcknowledge API with reason code. + */ + @Test + public void clientSendsPubAckWithReasonCode(TestContext ctx) { + Async serverPublishAcked = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS1), + MqttProperties.NO_PROPERTIES); + + // Wait for client's PUBACK then mark done + endpoint.publishAcknowledgeHandler(id -> serverPublishAcked.complete()); + + // Push a QoS 1 message to the client after subscribe is acknowledged + endpoint.publish(MQTT_TOPIC, Buffer.buffer("server-push"), MqttQoS.AT_LEAST_ONCE, false, false); + }); + + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + options.setAutoAck(false); // we drive PUBACK ourselves + + MqttClient client = MqttClient.create(vertx, options); + + // On incoming PUBLISH QoS1 → send PUBACK with SUCCESS reason code + client.publishHandler(msg -> { + if (msg.qosLevel() == MqttQoS.AT_LEAST_ONCE) { + client.publishAcknowledge(msg.messageId(), MqttPubAckReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES); + } + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 1)))); + }); + + serverPublishAcked.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + // PUBLISH with MQTT 5.0 properties + // ----------------------------------------------------------------------- + + /** + * Client publishes QoS 0 with USER_PROPERTY; server verifies the property arrives. + */ + @Test + public void publishWithUserProperties(TestContext ctx) { + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> { + MqttProperties.MqttProperty prop = msg.properties().getProperty(MqttProperties.MqttPropertyType.USER_PROPERTY.value()); + ctx.assertNotNull(prop); + List pairs = (List) prop.value(); + ctx.assertFalse(pairs.isEmpty()); + ctx.assertEquals("k1", pairs.get(0).key); + ctx.assertEquals("v1", pairs.get(0).value); + serverReceived.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.UserProperties( + java.util.Arrays.asList(new MqttProperties.StringPair("k1", "v1")))); + client.publish(MQTT_TOPIC, Buffer.buffer("user-props"), MqttQoS.AT_MOST_ONCE, false, false, props); + })); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * Client publishes with MESSAGE_EXPIRY_INTERVAL; server verifies the property arrives. + */ + @Test + public void publishWithMessageExpiryInterval(TestContext ctx) { + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> { + MqttProperties.MqttProperty prop = msg.properties().getProperty(MqttProperties.MqttPropertyType.PUBLICATION_EXPIRY_INTERVAL.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(60L, Integer.toUnsignedLong((Integer) prop.value())); + serverReceived.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.PUBLICATION_EXPIRY_INTERVAL.value(), 60)); + client.publish(MQTT_TOPIC, Buffer.buffer("expiry"), MqttQoS.AT_MOST_ONCE, false, false, props); + })); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * Client publishes with CONTENT_TYPE; server verifies the property arrives. + */ + @Test + public void publishWithContentType(TestContext ctx) { + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> { + MqttProperties.MqttProperty prop = msg.properties().getProperty(MqttProperties.MqttPropertyType.CONTENT_TYPE.value()); + ctx.assertNotNull(prop); + ctx.assertEquals("application/json", prop.value()); + serverReceived.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.CONTENT_TYPE.value(), "application/json")); + client.publish(MQTT_TOPIC, Buffer.buffer("{\"key\":\"val\"}"), MqttQoS.AT_MOST_ONCE, false, false, props); + })); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * Client publishes with RESPONSE_TOPIC and CORRELATION_DATA (request-response pattern). + * Server verifies both properties arrive. + */ + @Test + public void publishWithResponseTopicAndCorrelationData(TestContext ctx) { + Async serverReceived = ctx.async(); + String responseTopic = "/reply/topic"; + byte[] correlationData = new byte[]{0x01, 0x02, 0x03}; + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> { + MqttProperties.MqttProperty rtProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value()); + MqttProperties.MqttProperty cdProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value()); + ctx.assertNotNull(rtProp); + ctx.assertEquals(responseTopic, rtProp.value()); + ctx.assertNotNull(cdProp); + ctx.assertEquals(Buffer.buffer(correlationData), Buffer.buffer((byte[]) cdProp.value())); + serverReceived.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value(), responseTopic)); + props.add(new MqttProperties.BinaryProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value(), correlationData)); + client.publish(MQTT_TOPIC, Buffer.buffer("request"), MqttQoS.AT_MOST_ONCE, false, false, props); + })); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * Full MQTT 5.0 request-response pattern using RESPONSE_TOPIC and CORRELATION_DATA. + * + * Flow: + * 1. Client subscribes to the response topic. + * 2. Client publishes a request on MQTT_TOPIC carrying RESPONSE_TOPIC and CORRELATION_DATA. + * 3. Server receives the request, extracts both properties, and publishes a response + * on RESPONSE_TOPIC echoing back the same CORRELATION_DATA. + * 4. Client receives the response and verifies the CORRELATION_DATA matches the original. + */ + @Test + public void correlationDataRoundTrip(TestContext ctx) { + Async responseReceived = ctx.async(); + String responseTopic = "/reply/correlation-test"; + byte[] correlationData = new byte[]{0x0A, 0x0B, 0x0C, 0x0D}; + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS0), + MqttProperties.NO_PROPERTIES)); + + endpoint.publishHandler(msg -> { + // Extract response topic and correlation data from the incoming request + MqttProperties.MqttProperty rtProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value()); + MqttProperties.MqttProperty cdProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value()); + ctx.assertNotNull(rtProp); + ctx.assertNotNull(cdProp); + + // Echo correlation data back in the response publish + MqttProperties responseProps = new MqttProperties(); + responseProps.add(new MqttProperties.BinaryProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value(), + (byte[]) cdProp.value())); + endpoint.publish((String) rtProp.value(), Buffer.buffer("response"), + MqttQoS.AT_MOST_ONCE, false, false, 0, responseProps); + }); + + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.publishHandler(msg -> { + // Verify the echoed correlation data matches what we sent + MqttProperties.MqttProperty cdProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value()); + ctx.assertNotNull(cdProp); + ctx.assertEquals(Buffer.buffer(correlationData), Buffer.buffer((byte[]) cdProp.value())); + responseReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + // Subscribe to the response topic first + client.subscribe(java.util.Collections.singletonMap(responseTopic, 0)) + .onComplete(ctx.asyncAssertSuccess(subAck -> { + // Then publish the request with RESPONSE_TOPIC + CORRELATION_DATA + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value(), responseTopic)); + props.add(new MqttProperties.BinaryProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value(), correlationData)); + client.publish(MQTT_TOPIC, Buffer.buffer("request"), MqttQoS.AT_MOST_ONCE, false, false, props); + })); + })); + }); + + responseReceived.awaitSuccess(5000); + } + + /** + * Client publishes with PAYLOAD_FORMAT_INDICATOR=1 (UTF-8); server verifies. + */ + @Test + public void publishWithPayloadFormatIndicator(TestContext ctx) { + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.publishHandler(msg -> { + MqttProperties.MqttProperty prop = msg.properties().getProperty(MqttProperties.MqttPropertyType.PAYLOAD_FORMAT_INDICATOR.value()); + ctx.assertNotNull(prop); + ctx.assertEquals(1, prop.value()); + serverReceived.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.PAYLOAD_FORMAT_INDICATOR.value(), 1)); + client.publish(MQTT_TOPIC, Buffer.buffer("UTF-8 text"), MqttQoS.AT_MOST_ONCE, false, false, props); + })); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * Server publishes to client with USER_PROPERTY; client verifies the property arrives + * via the publishHandler properties(). + */ + @Test + public void serverPublishWithPropertiesReceivedByClient(TestContext ctx) { + Async clientReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS0), + MqttProperties.NO_PROPERTIES); + + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.CONTENT_TYPE.value(), "text/plain")); + endpoint.publish(MQTT_TOPIC, Buffer.buffer("server-msg"), MqttQoS.AT_MOST_ONCE, false, false, 0, props); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + + client.publishHandler(msg -> { + MqttProperties.MqttProperty prop = msg.properties().getProperty(MqttProperties.MqttPropertyType.CONTENT_TYPE.value()); + ctx.assertNotNull(prop); + ctx.assertEquals("text/plain", prop.value()); + clientReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 0)))); + }); + + clientReceived.awaitSuccess(5000); + } + + /** + * When the server sends MAXIMUM_PACKET_SIZE=50 in CONNACK, a publish with a + * 100-byte payload must be rejected client-side with MQTT_PACKET_TOO_LARGE. + */ + @Test + public void publishExceedsMaximumPacketSize(TestContext ctx) { + Async rejected = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties connAckProps = new MqttProperties(); + connAckProps.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.MAXIMUM_PACKET_SIZE.value(), 50)); + endpoint.accept(false, connAckProps); + + // If client erroneously sends the packet, fail the test + endpoint.publishHandler(msg -> ctx.fail("Client must NOT have sent the oversized packet")); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + Buffer bigPayload = Buffer.buffer(new byte[100]); + client.publish("/test/topic", bigPayload, MqttQoS.AT_MOST_ONCE, false, false) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof MqttException, "Expected MqttException"); + ctx.assertEquals(MqttException.MQTT_PACKET_TOO_LARGE, ((MqttException) err).code()); + rejected.complete(); + })); + })); + }); + + rejected.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + // PUBACK / PUBCOMP reason codes exposed to handler + // ----------------------------------------------------------------------- + + /** + * Server sends PUBACK with NOT_AUTHORIZED. + * Client's publishAckMessageHandler must fire with that reason code. + * publishCompletionHandler must NOT fire (packet was not acknowledged successfully). + */ + @Test + public void pubAckReasonCodeExposedToHandler(TestContext ctx) { + Async done = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false); + endpoint.publishHandler(msg -> + endpoint.publishAcknowledge(msg.messageId(), + MqttPubAckReasonCode.NOT_AUTHORIZED, MqttProperties.NO_PROPERTIES)); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.publishAckMessageHandler(ack -> { + ctx.assertEquals(MqttPubAckReasonCode.NOT_AUTHORIZED, ack.code()); + done.complete(); + }); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(connAck -> + client.publish(MQTT_TOPIC, Buffer.buffer("hello"), MqttQoS.AT_LEAST_ONCE, false, false))); + }); + + done.awaitSuccess(5000); + } + + /** + * Server sends PUBCOMP with PACKET_IDENTIFIER_NOT_FOUND (error code). + * Client's publishCompMessageHandler must fire with that reason code. + */ + @Test + public void pubCompReasonCodeExposedToHandler(TestContext ctx) { + Async done = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false); + endpoint.publishHandler(msg -> + endpoint.publishReceived(msg.messageId(), + MqttPubRecReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES)); + endpoint.publishReleaseHandler(msgId -> + endpoint.publishComplete(msgId, + MqttPubCompReasonCode.PACKET_IDENTIFIER_NOT_FOUND, MqttProperties.NO_PROPERTIES)); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.publishCompMessageHandler(comp -> { + ctx.assertEquals(MqttPubCompReasonCode.PACKET_IDENTIFIER_NOT_FOUND, comp.code()); + done.complete(); + }); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(connAck -> + client.publish(MQTT_TOPIC, Buffer.buffer("hello"), MqttQoS.EXACTLY_ONCE, false, false))); + }); + + done.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + return opts; + } + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscribeTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscribeTest.java new file mode 100644 index 00000000..61aaa82b --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscribeTest.java @@ -0,0 +1,395 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.MqttSubAckMessage; +import io.vertx.mqtt.MqttException; +import io.vertx.mqtt.messages.codes.MqttSubAckReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; +import java.util.Map; + +/** + * Tests for the MQTT v5 client SUBSCRIBE and SUBACK flow. + * Verifies that: + *

    + *
  • SUBSCRIBE with MQTT properties is sent correctly and properties are received server-side.
  • + *
  • SUBACK reason codes (from the server) are surfaced in the client's subscribeCompletionHandler.
  • + *
+ */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientSubscribeTest { + + private static final String MQTT_TOPIC = "/mqtt5/test"; + private static final int SUBSCRIPTION_IDENTIFIER = 42; + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + + /** + * SUBSCRIBE carrying a SUBSCRIPTION_IDENTIFIER must arrive on the server + * with the identifier intact. + */ + @Test + public void subscribeWithSubscriptionIdentifier(TestContext ctx) { + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + MqttProperties.MqttProperty prop = subscribe.properties().getProperty(MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value()); + ctx.assertNotNull(prop, "SUBSCRIPTION_IDENTIFIER property must be present"); + ctx.assertEquals(SUBSCRIPTION_IDENTIFIER, prop.value()); + + // Acknowledge — grant the requested QoS + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.qosGranted(subscribe.topicSubscriptions().get(0).qualityOfService())), + MqttProperties.NO_PROPERTIES); + + serverReceived.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value(), SUBSCRIPTION_IDENTIFIER)); + client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 1), props); + })); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * Server grants QoS 1 in SUBACK — the client's subscribeCompletionHandler must + * see a MqttSubAckMessage with the granted code. + */ + @Test + public void subscribeAckGrantedQos1(TestContext ctx) { + Async ackReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS1), + MqttProperties.NO_PROPERTIES)); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.subscribeCompletionHandler((MqttSubAckMessage ack) -> { + ctx.assertEquals(1, ack.grantedQoSLevels().size()); + ctx.assertEquals((int) MqttSubAckReasonCode.GRANTED_QOS1.value(), ack.grantedQoSLevels().get(0).intValue()); + ackReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 1)))); + }); + + ackReceived.awaitSuccess(5000); + } + + /** + * Server refuses the subscription with QUOTA_EXCEEDED — client sees + * the error reason code in the SUBACK. + */ + @Test + public void subscribeAckError(TestContext ctx) { + Async ackReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.UNSPECIFIED_ERROR), + MqttProperties.NO_PROPERTIES)); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.subscribeCompletionHandler((MqttSubAckMessage ack) -> { + ctx.assertEquals(1, ack.grantedQoSLevels().size()); + ctx.assertEquals(MqttSubAckReasonCode.UNSPECIFIED_ERROR.value(), ack.grantedQoSLevels().get(0).byteValue()); + ackReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> client.subscribe(java.util.Collections.singletonMap("/topic", MqttQoS.AT_LEAST_ONCE.value())))); + }); + + ackReceived.awaitSuccess(5000); + } + + /** + * SUBACK with a reason-string property in MqttProperties must be surfaced + * on the client via MqttSubAckMessage.properties(). + */ + @Test + public void subscribeAckWithReasonStringProperty(TestContext ctx) { + String reasonString = "test-reason"; + Async ackReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + MqttProperties subackProps = new MqttProperties(); + subackProps.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.REASON_STRING.value(), reasonString)); + endpoint.subscribeAcknowledge(subscribe.messageId(), java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS0), subackProps); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.subscribeCompletionHandler((MqttSubAckMessage ack) -> { + MqttProperties.MqttProperty prop = ack.properties().getProperty(MqttProperties.MqttPropertyType.REASON_STRING.value()); + ctx.assertNotNull(prop, "REASON_STRING property must be present in SUBACK"); + ctx.assertEquals(reasonString, prop.value()); + ackReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 0)))); + }); + + ackReceived.awaitSuccess(5000); + } + + /** + * When the server sends SUBSCRIPTION_IDENTIFIER_AVAILABLE=0 in CONNACK, + * any SUBSCRIBE with a SUBSCRIPTION_IDENTIFIER property must be rejected + * client-side with MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED. + */ + @Test + public void subscribeWithIdentifierRejectedWhenServerDisablesIt(TestContext ctx) { + Async rejected = ctx.async(); + + server.endpointHandler(endpoint -> { + // Server explicitly disables subscription identifiers + MqttProperties connAckProps = new MqttProperties(); + connAckProps.add(new MqttProperties.IntegerProperty( + MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER_AVAILABLE.value(), 0)); + endpoint.accept(false, connAckProps); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value(), SUBSCRIPTION_IDENTIFIER)); + + client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 1), props) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof MqttException, "Expected MqttException"); + ctx.assertEquals(MqttException.MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED, + ((MqttException) err).code()); + rejected.complete(); + })); + })); + }); + + rejected.awaitSuccess(5000); + } + + /** + * A SUBSCRIBE with a SUBSCRIPTION_IDENTIFIER on a non-MQTT-5 connection must + * be rejected client-side with MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED. + */ + @Test + public void subscribeWithIdentifierRejectedOnMqtt4(TestContext ctx) { + Async rejected = ctx.async(); + + server.endpointHandler(endpoint -> endpoint.accept(false)); + + startServer(ctx, () -> { + // Plain MQTT 4 client + MqttClientOptions opts = new MqttClientOptions(); + MqttClient client = MqttClient.create(vertx, opts); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value(), SUBSCRIPTION_IDENTIFIER)); + + client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 1), props) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof MqttException, "Expected MqttException"); + ctx.assertEquals(MqttException.MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED, + ((MqttException) err).code()); + rejected.complete(); + })); + })); + }); + + rejected.awaitSuccess(5000); + } + + /** + * When the server sends WILDCARD_SUBSCRIPTION_AVAILABLE=0 in CONNACK, + * any SUBSCRIBE with a wildcard topic filter must be rejected client-side + * with MQTT_WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED. + */ + @Test + public void subscribeWithWildcardRejectedWhenServerDisablesIt(TestContext ctx) { + Async rejected = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties connAckProps = new MqttProperties(); + connAckProps.add(new MqttProperties.IntegerProperty( + MqttProperties.MqttPropertyType.WILDCARD_SUBSCRIPTION_AVAILABLE.value(), 0)); + endpoint.accept(false, connAckProps); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertFalse(ack.wildcardSubscriptionAvailable(), + "wildcardSubscriptionAvailable() must return false"); + + client.subscribe(java.util.Collections.singletonMap("/wildcard/+/topic", 1)) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof MqttException, "Expected MqttException"); + ctx.assertEquals(MqttException.MQTT_WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED, + ((MqttException) err).code()); + rejected.complete(); + })); + })); + }); + + rejected.awaitSuccess(5000); + } + + /** + * When the server sends SHARED_SUBSCRIPTION_AVAILABLE=0 in CONNACK, + * any SUBSCRIBE with a $share/... topic filter must be rejected client-side + * with MQTT_SHARED_SUBSCRIPTIONS_NOT_SUPPORTED. + */ + @Test + public void subscribeWithSharedRejectedWhenServerDisablesIt(TestContext ctx) { + Async rejected = ctx.async(); + + server.endpointHandler(endpoint -> { + MqttProperties connAckProps = new MqttProperties(); + connAckProps.add(new MqttProperties.IntegerProperty( + MqttProperties.MqttPropertyType.SHARED_SUBSCRIPTION_AVAILABLE.value(), 0)); + endpoint.accept(false, connAckProps); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertFalse(ack.sharedSubscriptionAvailable(), + "sharedSubscriptionAvailable() must return false"); + + client.subscribe(java.util.Collections.singletonMap("$share/group/topic", 1)) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof MqttException, "Expected MqttException"); + ctx.assertEquals(MqttException.MQTT_SHARED_SUBSCRIPTIONS_NOT_SUPPORTED, + ((MqttException) err).code()); + rejected.complete(); + })); + })); + }); + + rejected.awaitSuccess(5000); + } + + /** + * When the server omits WILDCARD_SUBSCRIPTION_AVAILABLE in CONNACK (default = supported), + * wildcardSubscriptionAvailable() must return null and wildcard subscriptions must succeed. + */ + @Test + public void wildcardSubscriptionAvailableNullWhenAbsent(TestContext ctx) { + Async done = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(sub -> + endpoint.subscribeAcknowledge(sub.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS0), MqttProperties.NO_PROPERTIES)); + endpoint.accept(false); // no WILDCARD_SUBSCRIPTION_AVAILABLE property + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + ctx.assertNull(ack.wildcardSubscriptionAvailable(), + "wildcardSubscriptionAvailable() must be null when absent"); + client.subscribe(java.util.Collections.singletonMap("/wildcard/#", 0)) + .onComplete(ctx.asyncAssertSuccess(id -> done.complete())); + })); + }); + + done.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + return opts; + } + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscriptionOptionsTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscriptionOptionsTest.java new file mode 100644 index 00000000..95a61dd5 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientSubscriptionOptionsTest.java @@ -0,0 +1,219 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttSubscriptionOption; +import io.netty.handler.codec.mqtt.MqttSubscriptionOption.RetainedHandlingPolicy; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.codes.MqttSubAckReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +/** + * Tests for MQTT 5.0 subscription options (No Local, Retain As Published, + * Retain Handling) via the new + * {@link MqttClient#subscribe(List, MqttProperties)} API. + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientSubscriptionOptionsTest { + + private static final String TOPIC = "/mqtt5/sub/options"; + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> + vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + + /** + * Subscribe with NO_LOCAL=true: the server sees noLocal flag in subscription options. + */ + @Test + public void subscribeWithNoLocal(TestContext ctx) { + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + ctx.assertFalse(subscribe.topicSubscriptions().isEmpty()); + MqttSubscriptionOption opt = subscribe.topicSubscriptions().get(0).subscriptionOption(); + ctx.assertTrue(opt.isNoLocal()); + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS0), MqttProperties.NO_PROPERTIES); + serverLatch.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttSubscriptionOption option = new MqttSubscriptionOption( + MqttQoS.AT_MOST_ONCE, true, false, RetainedHandlingPolicy.SEND_AT_SUBSCRIBE); + client.subscribe( + java.util.Arrays.asList(new MqttTopicSubscription(TOPIC, option)), + MqttProperties.NO_PROPERTIES); + })); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * Subscribe with RETAIN_AS_PUBLISHED=true: the server sees retainAsPublished in options. + */ + @Test + public void subscribeWithRetainAsPublished(TestContext ctx) { + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + MqttSubscriptionOption opt = subscribe.topicSubscriptions().get(0).subscriptionOption(); + ctx.assertTrue(opt.isRetainAsPublished()); + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS1), MqttProperties.NO_PROPERTIES); + serverLatch.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttSubscriptionOption option = new MqttSubscriptionOption( + MqttQoS.AT_LEAST_ONCE, false, true, RetainedHandlingPolicy.SEND_AT_SUBSCRIBE); + client.subscribe( + java.util.Arrays.asList(new MqttTopicSubscription(TOPIC, option)), + MqttProperties.NO_PROPERTIES); + })); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * Subscribe with each RetainedHandlingPolicy value: server verifies the correct policy. + */ + @Test + public void subscribeWithRetainHandlingDontSend(TestContext ctx) { + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + MqttSubscriptionOption opt = subscribe.topicSubscriptions().get(0).subscriptionOption(); + ctx.assertEquals(RetainedHandlingPolicy.DONT_SEND_AT_SUBSCRIBE, opt.retainHandling()); + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS2), MqttProperties.NO_PROPERTIES); + serverLatch.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttSubscriptionOption option = new MqttSubscriptionOption( + MqttQoS.EXACTLY_ONCE, false, false, RetainedHandlingPolicy.DONT_SEND_AT_SUBSCRIBE); + client.subscribe( + java.util.Arrays.asList(new MqttTopicSubscription(TOPIC, option)), + MqttProperties.NO_PROPERTIES); + })); + }); + + serverLatch.awaitSuccess(5000); + } + + /** + * All three subscription options active at once. + */ + @Test + public void subscribeWithAllOptions(TestContext ctx) { + Async serverLatch = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> { + MqttSubscriptionOption opt = subscribe.topicSubscriptions().get(0).subscriptionOption(); + ctx.assertTrue(opt.isNoLocal()); + ctx.assertTrue(opt.isRetainAsPublished()); + ctx.assertEquals(RetainedHandlingPolicy.SEND_AT_SUBSCRIBE_IF_NOT_YET_EXISTS, opt.retainHandling()); + ctx.assertEquals(MqttQoS.AT_LEAST_ONCE, opt.qos()); + endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS1), MqttProperties.NO_PROPERTIES); + serverLatch.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + MqttSubscriptionOption option = new MqttSubscriptionOption( + MqttQoS.AT_LEAST_ONCE, true, true, RetainedHandlingPolicy.SEND_AT_SUBSCRIBE_IF_NOT_YET_EXISTS); + client.subscribe( + java.util.Arrays.asList(new MqttTopicSubscription(TOPIC, option)), + MqttProperties.NO_PROPERTIES); + })); + }); + + serverLatch.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + return opts; + } + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java new file mode 100644 index 00000000..1aa84eec --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.MqttServerOptions; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tests for automatic MQTT 5.0 topic alias management in MqttClientImpl. + * The server advertises TOPIC_ALIAS_MAXIMUM in the CONNACK; the client must + * automatically assign and reuse aliases when publishing. + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientTopicAliasTest { + + private static final String TOPIC = "/mqtt5/alias/test"; + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> + vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + + /** + * First PUBLISH to a topic: the wire packet must carry the full topic name + * AND a TOPIC_ALIAS property. + */ + @Test + public void firstPublishCarriesTopicNameAndAlias(TestContext ctx) { + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + // Advertise alias support: accept up to 10 aliases + endpoint.accept(false, buildConnAckProps(10)); + + endpoint.publishHandler(msg -> { + // Full topic name must be present on first publish + ctx.assertEquals(TOPIC, msg.topicName()); + // TOPIC_ALIAS property must be present + MqttProperties.MqttProperty aliasProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value()); + ctx.assertNotNull(aliasProp); + ctx.assertTrue((Integer) aliasProp.value() >= 1); + serverReceived.complete(); + }); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(TOPIC, Buffer.buffer("first"), io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE, false, false))); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * Second PUBLISH to the same topic: the wire packet must carry an EMPTY topic name + * and the same TOPIC_ALIAS as the first publish (alias reuse). + */ + @Test + public void secondPublishReusesAlias(TestContext ctx) { + Async firstReceived = ctx.async(); + Async secondReceived = ctx.async(); + AtomicInteger capturedAlias = new AtomicInteger(-1); + + server.endpointHandler(endpoint -> { + endpoint.accept(false, buildConnAckProps(10)); + + AtomicInteger count = new AtomicInteger(0); + endpoint.publishHandler(msg -> { + int n = count.incrementAndGet(); + if (n == 1) { + // First publish: topic name present, alias assigned + ctx.assertFalse(msg.topicName().isEmpty()); + MqttProperties.MqttProperty aliasProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value()); + ctx.assertNotNull(aliasProp); + capturedAlias.set((Integer) aliasProp.value()); + firstReceived.complete(); + } else if (n == 2) { + // Second publish: alias resolved server-side, topic name must be non-empty + ctx.assertEquals(TOPIC, msg.topicName()); + MqttProperties.MqttProperty aliasProp = msg.properties().getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value()); + ctx.assertNotNull(aliasProp); + ctx.assertEquals(capturedAlias.get(), aliasProp.value()); + secondReceived.complete(); + } + }); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + client.publish(TOPIC, Buffer.buffer("first"), io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE, false, false); + client.publish(TOPIC, Buffer.buffer("second"), io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE, false, false); + })); + }); + + firstReceived.awaitSuccess(5000); + secondReceived.awaitSuccess(5000); + } + + /** + * When the server advertises TOPIC_ALIAS_MAXIMUM=0 (or does not include it), + * the client must NOT add any TOPIC_ALIAS property to PUBLISH packets. + */ + @Test + public void topicAliasDisabledWhenServerSaysZero(TestContext ctx) { + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + // Explicitly advertise 0 aliases supported + endpoint.accept(false, buildConnAckProps(0)); + + endpoint.publishHandler(msg -> { + // Full topic name must be present + ctx.assertEquals(TOPIC, msg.topicName()); + // No TOPIC_ALIAS property + ctx.assertNull(msg.properties().getProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value())); + serverReceived.complete(); + }); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> + client.publish(TOPIC, Buffer.buffer("no-alias"), io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE, false, false))); + }); + + serverReceived.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + /** Build MqttProperties for CONNACK with TOPIC_ALIAS_MAXIMUM set. */ + private MqttProperties buildConnAckProps(int topicAliasMaximum) { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS_MAXIMUM.value(), topicAliasMaximum)); + return props; + } + + private MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + // Disable client-side alias sending by default so each test controls via server CONNACK + opts.setTopicAliasMaximum(null); + return opts; + } + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientUnsubscribeTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientUnsubscribeTest.java new file mode 100644 index 00000000..6fc2b47c --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientUnsubscribeTest.java @@ -0,0 +1,225 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.MqttUnsubAckMessage; +import io.vertx.mqtt.messages.codes.MqttSubAckReasonCode; +import io.vertx.mqtt.messages.codes.MqttUnsubAckReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; +import java.util.Map; + +/** + * Tests for the MQTT v5 client UNSUBSCRIBE and UNSUBACK flow. + * Verifies that: + *
    + *
  • UNSUBSCRIBE with MQTT properties is sent correctly and properties reach the server.
  • + *
  • UNSUBACK reason codes (from the server) are surfaced in the client via + * {@link MqttClient#unsubscribeCompletionMessageHandler}.
  • + *
+ */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientUnsubscribeTest { + + private static final String MQTT_TOPIC = "/mqtt5/unsub/test"; + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + + /** + * Client subscribes, then unsubscribes — server should fire unsubscribeHandler + * and acknowledge with SUCCESS. The client's unsubscribeCompletionMessageHandler + * must receive an MqttUnsubAckMessage with the SUCCESS reason code. + */ + @Test + public void unsubscribeSuccessReasonCode(TestContext ctx) { + Async ackReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.subscribeHandler(subscribe -> endpoint.subscribeAcknowledge(subscribe.messageId(), + java.util.Arrays.asList(MqttSubAckReasonCode.GRANTED_QOS0), MqttProperties.NO_PROPERTIES)); + + endpoint.unsubscribeHandler(unsubscribe -> endpoint.unsubscribeAcknowledge(unsubscribe.messageId(), + java.util.Arrays.asList(MqttUnsubAckReasonCode.SUCCESS), MqttProperties.NO_PROPERTIES)); + + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.unsubscribeCompletionMessageHandler((MqttUnsubAckMessage ack) -> { + ctx.assertEquals(1, ack.reasonCodes().size()); + ctx.assertEquals(MqttUnsubAckReasonCode.SUCCESS.value(), ack.reasonCodes().get(0).byteValue()); + ackReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(connAck -> client.subscribe(java.util.Collections.singletonMap(MQTT_TOPIC, 0)) + .onComplete(ctx.asyncAssertSuccess(subId -> client.unsubscribe(java.util.Arrays.asList(MQTT_TOPIC)))))); + }); + + ackReceived.awaitSuccess(5000); + } + + /** + * Server sends UNSUBACK with NO_SUBSCRIPTION_FOUND reason code — client + * must see this code via unsubscribeCompletionMessageHandler. + */ + @Test + public void unsubscribeNotSubscribedReasonCode(TestContext ctx) { + Async ackReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.unsubscribeHandler(unsubscribe -> endpoint.unsubscribeAcknowledge(unsubscribe.messageId(), + java.util.Arrays.asList(MqttUnsubAckReasonCode.NO_SUBSCRIPTION_EXISTED), MqttProperties.NO_PROPERTIES)); + + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.unsubscribeCompletionMessageHandler((MqttUnsubAckMessage ack) -> { + ctx.assertEquals(1, ack.reasonCodes().size()); + ctx.assertEquals(MqttUnsubAckReasonCode.NO_SUBSCRIPTION_EXISTED.value(), ack.reasonCodes().get(0).byteValue()); + ackReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(connAck -> client.unsubscribe(java.util.Arrays.asList(MQTT_TOPIC)))); + }); + + ackReceived.awaitSuccess(5000); + } + + /** + * Unsubscribe with MQTT properties; server verifies the USER_PROPERTY is received. + */ + @Test + public void unsubscribeWithProperties(TestContext ctx) { + String userPropKey = "client-id"; + String userPropValue = "test-client"; + Async serverReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.unsubscribeHandler(unsubscribe -> { + // Echo back that we received the message (testing the send-side) + endpoint.unsubscribeAcknowledge(unsubscribe.messageId(), + java.util.Arrays.asList(MqttUnsubAckReasonCode.SUCCESS), MqttProperties.NO_PROPERTIES); + serverReceived.complete(); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(connAck -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.UserProperty(userPropKey, userPropValue)); + client.unsubscribe(java.util.Arrays.asList(MQTT_TOPIC), props); + })); + }); + + serverReceived.awaitSuccess(5000); + } + + /** + * UNSUBACK with a REASON_STRING property must be available via + * MqttUnsubAckMessage.properties(). + */ + @Test + public void unsubscribeAckWithReasonStringProperty(TestContext ctx) { + String reasonString = "no-such-subscription"; + Async ackReceived = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.unsubscribeHandler(unsubscribe -> { + MqttProperties subackProps = new MqttProperties(); + subackProps.add(new MqttProperties.StringProperty(MqttProperties.MqttPropertyType.REASON_STRING.value(), reasonString)); + endpoint.unsubscribeAcknowledge(unsubscribe.messageId(), + java.util.Arrays.asList(MqttUnsubAckReasonCode.NO_SUBSCRIPTION_EXISTED), subackProps); + }); + endpoint.accept(false); + }); + + startServer(ctx, () -> { + MqttClientOptions options = v5Options(); + MqttClient client = MqttClient.create(vertx, options); + + client.unsubscribeCompletionMessageHandler((MqttUnsubAckMessage ack) -> { + MqttProperties.MqttProperty prop = ack.properties().getProperty(MqttProperties.MqttPropertyType.REASON_STRING.value()); + ctx.assertNotNull(prop, "REASON_STRING must be present in UNSUBACK"); + ctx.assertEquals(reasonString, prop.value()); + ackReceived.complete(); + }); + + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(connAck -> client.unsubscribe(java.util.Arrays.asList(MQTT_TOPIC)))); + }); + + ackReceived.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + return opts; + } + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientWillTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientWillTest.java new file mode 100644 index 00000000..63bbd147 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientWillTest.java @@ -0,0 +1,172 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttClientWillOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.MqttWill; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.List; + +/** + * Tests for MQTT 5.0 Will message properties (§3.1.3.2). + * Verifies that all 6 will properties are correctly encoded by MqttClientImpl + * and arrive at the server endpoint. + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientWillTest { + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ----------------------------------------------------------------------- + + /** + * All 6 MQTT 5.0 will properties must survive the CONNECT encoding and be + * visible on the server side via {@code endpoint.will().getWillProperties()}. + */ + @Test + public void willMqtt5PropertiesAreSentCorrectly(TestContext ctx) { + Async async = ctx.async(); + + final String expectedTopic = "will/topic"; + final Buffer expectedPayload = Buffer.buffer("goodbye"); + final int expectedQos = 1; + final long expectedWillDelay = 30L; + final int expectedFormatIndicator = 1; // UTF-8 + final String expectedContentType = "text/plain"; + final String expectedResponseTopic = "response/topic"; + final Buffer expectedCorrelation = Buffer.buffer("corr-id-42"); + final String expectedUserKey = "reason"; + final String expectedUserVal = "shutdown"; + + server.endpointHandler(endpoint -> { + MqttWill will = endpoint.will(); + + ctx.assertTrue(will.isWillFlag(), "willFlag must be true"); + ctx.assertEquals(expectedTopic, will.getWillTopic()); + ctx.assertEquals(expectedPayload, will.getWillMessage()); + ctx.assertEquals(expectedQos, will.getWillQos()); + + MqttProperties props = will.getWillProperties(); + ctx.assertNotNull(props, "will properties must not be null"); + + // WILL_DELAY_INTERVAL + MqttProperties.MqttProperty willDelay = + props.getProperty(MqttProperties.MqttPropertyType.WILL_DELAY_INTERVAL.value()); + ctx.assertNotNull(willDelay, "WILL_DELAY_INTERVAL must be present"); + ctx.assertEquals((int) expectedWillDelay, willDelay.value()); + + // PAYLOAD_FORMAT_INDICATOR + MqttProperties.MqttProperty formatIndicator = + props.getProperty(MqttProperties.MqttPropertyType.PAYLOAD_FORMAT_INDICATOR.value()); + ctx.assertNotNull(formatIndicator, "PAYLOAD_FORMAT_INDICATOR must be present"); + ctx.assertEquals(expectedFormatIndicator, formatIndicator.value()); + + // CONTENT_TYPE + MqttProperties.MqttProperty contentType = + props.getProperty(MqttProperties.MqttPropertyType.CONTENT_TYPE.value()); + ctx.assertNotNull(contentType, "CONTENT_TYPE must be present"); + ctx.assertEquals(expectedContentType, contentType.value()); + + // RESPONSE_TOPIC + MqttProperties.MqttProperty responseTopic = + props.getProperty(MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value()); + ctx.assertNotNull(responseTopic, "RESPONSE_TOPIC must be present"); + ctx.assertEquals(expectedResponseTopic, responseTopic.value()); + + // CORRELATION_DATA + MqttProperties.MqttProperty correlationData = + props.getProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value()); + ctx.assertNotNull(correlationData, "CORRELATION_DATA must be present"); + ctx.assertTrue(Arrays.equals(expectedCorrelation.getBytes(), (byte[]) correlationData.value()), + "CORRELATION_DATA bytes must match"); + + // USER_PROPERTY + MqttProperties.MqttProperty userProp = + props.getProperty(MqttProperties.MqttPropertyType.USER_PROPERTY.value()); + ctx.assertNotNull(userProp, "USER_PROPERTY must be present"); + @SuppressWarnings("unchecked") + List pairs = (List) userProp.value(); + ctx.assertFalse(pairs.isEmpty(), "USER_PROPERTY list must not be empty"); + ctx.assertEquals(expectedUserKey, pairs.get(0).key); + ctx.assertEquals(expectedUserVal, pairs.get(0).value); + + endpoint.accept(false); + async.complete(); + }); + + startServer(ctx, () -> { + MqttClientWillOptions willOpts = new MqttClientWillOptions() + .setTopic(expectedTopic) + .setMessageBytes(expectedPayload) + .setQos(expectedQos) + .setWillDelayInterval(expectedWillDelay) + .setPayloadFormatIndicator(expectedFormatIndicator) + .setContentType(expectedContentType) + .setResponseTopic(expectedResponseTopic) + .setCorrelationData(expectedCorrelation) + .addUserProperty(expectedUserKey, expectedUserVal); + + MqttClientOptions options = new MqttClientOptions(); + options.setVersion(MqttVersion.MQTT_5.protocolLevel()); + options.setWillOptions(willOpts); + + MqttClient client = MqttClient.create(vertx, options); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess()); + }); + + async.awaitSuccess(5000); + } + + // ----------------------------------------------------------------------- + + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + afterStart.run(); + })); + latch.awaitSuccess(5000); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java new file mode 100644 index 00000000..cf661e45 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java @@ -0,0 +1,212 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tests for MQTT 5.0 Server Redirection (SERVER_REFERENCE property). + * + * Scenarios: + * 1. CONNACK with SERVER_REFERENCE and connection refused → client auto-redirects, + * connect() Future succeeds against the target server. + * 2. Server sends DISCONNECT with SERVER_REFERENCE after connection is established → + * client auto-reconnects to the target server transparently. + * 3. autoServerRedirect=false → redirect is NOT performed; connect() Future fails. + * 4. SERVER_REFERENCE with a comma-separated list → client picks one at random. + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ServerRedirectTest { + + private Vertx vertx; + private MqttServer server1; + private MqttServer server2; + + @Before + public void before() { + vertx = Vertx.vertx(); + server1 = MqttServer.create(vertx); + server2 = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server1.close() + .compose(v -> server2.close()) + .compose(v -> vertx.close()) + .onComplete(ctx.asyncAssertSuccess()); + } + + // ------------------------------------------------------------------------- + + /** + * server1 refuses connection (SERVER_UNAVAILABLE) and includes a SERVER_REFERENCE + * pointing to server2. server2 accepts. + * The client's connect() Future must eventually succeed against server2. + */ + @Test + public void connackRedirectToNewServer(TestContext ctx) { + Async done = ctx.async(); + + server2.endpointHandler(ep -> { + ep.accept(false); + done.complete(); + }); + + // Start server2 first so its port is known, then configure server1 to redirect to it + server2.listen(0).onComplete(ctx.asyncAssertSuccess(v2 -> { + server1.endpointHandler(ep -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.SERVER_REFERENCE.value(), "localhost:" + server2.actualPort())); + ep.reject(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE, props); + }); + + server1.listen(0).onComplete(ctx.asyncAssertSuccess(v1 -> { + MqttClient client = MqttClient.create(vertx, v5Options(true)); + client.connect(server1.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess()); + })); + })); + + done.awaitSuccess(5000); + } + + /** + * server1 accepts the connection but immediately sends DISCONNECT with SERVER_REFERENCE + * pointing to server2. The client must reconnect to server2 transparently. + */ + @Test + public void disconnectRedirectToNewServer(TestContext ctx) { + Async done = ctx.async(); + + server2.endpointHandler(ep -> { + ep.accept(false); + done.complete(); + }); + + server2.listen(0).onComplete(ctx.asyncAssertSuccess(v2 -> { + server1.endpointHandler(ep -> { + ep.accept(false); + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.SERVER_REFERENCE.value(), "localhost:" + server2.actualPort())); + ep.disconnect(MqttDisconnectReasonCode.USE_ANOTHER_SERVER, props); + }); + + server1.listen(0).onComplete(ctx.asyncAssertSuccess(v1 -> { + MqttClient client = MqttClient.create(vertx, v5Options(true)); + client.connect(server1.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess()); + })); + })); + + done.awaitSuccess(5000); + } + + /** + * When autoServerRedirect=false the client must NOT follow the SERVER_REFERENCE + * in a failed CONNACK and the connect() Future must fail. + */ + @Test + public void connackRedirectDisabled(TestContext ctx) { + Async done = ctx.async(); + + server2.endpointHandler(ep -> { + ep.accept(false); + ctx.fail("Client must NOT have connected to server2"); + }); + + server2.listen(0).onComplete(ctx.asyncAssertSuccess(v2 -> { + server1.endpointHandler(ep -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.SERVER_REFERENCE.value(), "localhost:" + server2.actualPort())); + ep.reject(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE, props); + }); + + server1.listen(0).onComplete(ctx.asyncAssertSuccess(v1 -> { + MqttClient client = MqttClient.create(vertx, v5Options(false)); // redirect disabled + client.connect(server1.actualPort(), "localhost") + .onComplete(ar -> { + ctx.assertTrue(ar.failed()); + done.complete(); + }); + })); + })); + + done.awaitSuccess(5000); + } + + /** + * SERVER_REFERENCE contains a comma-separated list of two servers; the client + * must pick one at random and successfully connect to it. + */ + @Test + public void connackRedirectFromList(TestContext ctx) { + Async done = ctx.async(); + MqttServer server3 = MqttServer.create(vertx); + + server2.endpointHandler(ep -> { ep.accept(false); done.complete(); }); + server3.endpointHandler(ep -> { ep.accept(false); done.complete(); }); + + // Start server2 and server3 in parallel, then server1 + Future.all(server2.listen(0), server3.listen(0)) + .onComplete(ctx.asyncAssertSuccess(v -> { + server1.endpointHandler(ep -> { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.SERVER_REFERENCE.value(), + "localhost:" + server2.actualPort() + ", localhost:" + server3.actualPort())); + ep.reject(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE, props); + }); + + server1.listen(0).onComplete(ctx.asyncAssertSuccess(v1 -> { + MqttClient client = MqttClient.create(vertx, v5Options(true)); + client.connect(server1.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess()); + })); + })); + + done.awaitSuccess(5000); + server3.close(); + } + + // ------------------------------------------------------------------------- + + private MqttClientOptions v5Options(boolean autoServerRedirect) { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + opts.setAutoServerRedirect(autoServerRedirect); + return opts; + } +} diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5TopicAliasHandlingTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5TopicAliasHandlingTest.java new file mode 100644 index 00000000..91d29167 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5TopicAliasHandlingTest.java @@ -0,0 +1,388 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.mqtt.MqttConnectPayload; +import io.netty.handler.codec.mqtt.MqttConnectVariableHeader; +import io.netty.handler.codec.mqtt.MqttDecoder; +import io.netty.handler.codec.mqtt.MqttEncoder; +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageFactory; +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * Tests for MQTT 5.0 topic alias handling in both directions: + * - client → server (server-side decoding in MqttServerConnection) + * - server → client (client-side decoding in MqttClientImpl) + * + * The five scenarios mirror the MQTT 5.0 spec §3.3.2.3.4: + * 1. Alias defined correctly (happy path) + * 2. Alias used without prior definition → protocol error + * 3. Alias value exceeds declared maximum → protocol error (server→client direction) + * 4. Alias overwrite is legal + * 5. Empty topic + undefined alias → close connection (important edge case) + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5TopicAliasHandlingTest { + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> + vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ====================================================================== + // Test 1 – Alias definito correttamente (client → server) + // + // PUBLISH topic="a/b/c" alias=1 → server stores mapping 1 → "a/b/c" + // PUBLISH topic="" alias=1 → server resolves to "a/b/c" + // Both publishHandler invocations must see topicName = "a/b/c" + // ====================================================================== + @Test + public void test1_aliasDefinedCorrectly(TestContext ctx) { + Async first = ctx.async(); + Async second = ctx.async(); + AtomicInteger count = new AtomicInteger(); + + server.endpointHandler(endpoint -> { + // Server advertises TOPIC_ALIAS_MAXIMUM=10 → client will auto-assign aliases + endpoint.accept(false, buildConnAckProps(10)); + endpoint.publishHandler(msg -> { + int n = count.incrementAndGet(); + if (n == 1) { + ctx.assertEquals("a/b/c", msg.topicName()); + first.complete(); + } else if (n == 2) { + // Alias must have been resolved server-side; handler sees the real topic + ctx.assertEquals("a/b/c", msg.topicName()); + second.complete(); + } + }); + }); + + startServer(ctx, () -> { + MqttClient client = MqttClient.create(vertx, v5Options(255)); + client.connect(server.actualPort(), "localhost") + .onComplete(ctx.asyncAssertSuccess(ack -> { + // First publish: client sends full topic + alias; second: alias-only + client.publish("a/b/c", Buffer.buffer("1"), MqttQoS.AT_MOST_ONCE, false, false); + client.publish("a/b/c", Buffer.buffer("2"), MqttQoS.AT_MOST_ONCE, false, false); + })); + }); + + first.awaitSuccess(5000); + second.awaitSuccess(5000); + } + + // ====================================================================== + // Test 2 – Alias senza definizione (client → server) + // + // Alias 1 is first defined for "a/b/c". + // Then PUBLISH topic="" alias=2 arrives — alias 2 has never been mapped. + // The server must close the connection (protocol error). + // ====================================================================== + @Test + public void test2_aliasUndefined(TestContext ctx) throws InterruptedException { + Async serverClosed = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false, buildConnAckProps(10)); + endpoint.closeHandler(v -> serverClosed.complete()); + }); + + startServer(ctx, null); + + // Raw MQTT 5 client: first define alias 1, then use undefined alias 2 + rawMqtt5Test(server.actualPort(), 10, ch -> { + ch.writeAndFlush(buildPublish("a/b/c", 1)); // defines alias 1 + ch.writeAndFlush(buildPublish("", 2)); // alias 2 never defined → error + }); + + serverClosed.awaitSuccess(5000); + } + + // ====================================================================== + // Test 3 – Alias fuori range (server → client) + // + // Client declares TopicAliasMaximum = 10 in CONNECT. + // Server sends PUBLISH with alias = 11. + // Client must close the connection (TOPIC_ALIAS_INVALID). + // ====================================================================== + @Test + public void test3_aliasOutOfRange_serverToClient(TestContext ctx) { + Async clientClosed = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false); + // Send a PUBLISH to the client carrying alias=11 (beyond client's declared max of 10) + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value(), 11)); + endpoint.publish("some/topic", Buffer.buffer("data"), + MqttQoS.AT_MOST_ONCE, false, false, 0, props); + }); + + startServer(ctx, () -> { + // Client declares it accepts at most 10 aliases from the server + MqttClient client = MqttClient.create(vertx, v5Options(10)); + client.closeHandler(v -> clientClosed.complete()); + client.connect(server.actualPort(), "localhost"); + }); + + clientClosed.awaitSuccess(5000); + } + + // ====================================================================== + // Test 4 – Alias overwrite è legale (client → server) + // + // PUBLISH topic="a/b" alias=5 → server stores 5 → "a/b" + // PUBLISH topic="x/y" alias=5 → server OVERWRITES 5 → "x/y" + // PUBLISH topic="" alias=5 → server resolves to "x/y" + // All three messages must reach the publishHandler with the correct topic. + // ====================================================================== + @Test + public void test4_aliasOverwrite(TestContext ctx) throws InterruptedException { + Async first = ctx.async(); + Async second = ctx.async(); + Async third = ctx.async(); + AtomicInteger count = new AtomicInteger(); + + server.endpointHandler(endpoint -> { + // TOPIC_ALIAS_MAXIMUM=0 in CONNACK: the high-level client won't auto-manage aliases, + // so raw Netty sends the crafted packets we control. + endpoint.accept(false, buildConnAckProps(0)); + endpoint.publishHandler(msg -> { + int n = count.incrementAndGet(); + if (n == 1) { + ctx.assertEquals("a/b", msg.topicName()); + first.complete(); + } else if (n == 2) { + ctx.assertEquals("x/y", msg.topicName()); // mapping overwritten + second.complete(); + } else if (n == 3) { + ctx.assertEquals("x/y", msg.topicName()); // resolved after overwrite + third.complete(); + endpoint.close(); // clean up: close connection so rawMqtt5Test can return + } + }); + }); + + startServer(ctx, null); + + rawMqtt5Test(server.actualPort(), 10, ch -> { + ch.writeAndFlush(buildPublish("a/b", 5)); // define alias 5 → "a/b" + ch.writeAndFlush(buildPublish("x/y", 5)); // overwrite alias 5 → "x/y" + ch.writeAndFlush(buildPublish("", 5)); // resolve alias 5 → "x/y" + }); + + first.awaitSuccess(5000); + second.awaitSuccess(5000); + third.awaitSuccess(5000); + } + + // ====================================================================== + // Test 5 – Edge case: topic="" + alias mai definito → close connection + // (client → server) + // + // MQTT 5.0 §3.3.2.3.4: "It is a Protocol Error if the Topic Alias + // is not included in the Topic Alias Mappings." + // Many implementations miss this: they ignore the empty topic instead of + // closing. The server MUST close the connection. + // ====================================================================== + @Test + public void test5_emptyTopicUndefinedAlias(TestContext ctx) throws InterruptedException { + Async serverClosed = ctx.async(); + + server.endpointHandler(endpoint -> { + endpoint.accept(false, buildConnAckProps(10)); + endpoint.closeHandler(v -> serverClosed.complete()); + }); + + startServer(ctx, null); + + // Send PUBLISH with topic="" and alias=3 that was NEVER defined → protocol error + rawMqtt5Test(server.actualPort(), 10, ch -> + ch.writeAndFlush(buildPublish("", 3))); + + serverClosed.awaitSuccess(5000); + } + + // ====================================================================== + // Helpers + // ====================================================================== + + /** CONNACK properties advertising how many client-to-server aliases the server accepts. */ + private MqttProperties buildConnAckProps(int topicAliasMaximum) { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS_MAXIMUM.value(), topicAliasMaximum)); + return props; + } + + /** + * MqttClientOptions for MQTT 5 with the given topicAliasMaximum. + * This value is sent in CONNECT and controls how many server→client aliases the client accepts. + */ + private MqttClientOptions v5Options(int topicAliasMaximum) { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + opts.setTopicAliasMaximum(topicAliasMaximum); + return opts; + } + + /** Start the server on a random port; block until it is ready. */ + private void startServer(TestContext ctx, Runnable afterStart) { + Async latch = ctx.async(); + server.listen(0).onComplete(ctx.asyncAssertSuccess(v -> { + latch.complete(); + if (afterStart != null) afterStart.run(); + })); + latch.awaitSuccess(5000); + } + + /** + * Connect a raw MQTT 5 client (Netty Bootstrap + decoder/encoder), + * send CONNECT declaring {@code clientTopicAliasMaximum}, + * wait for CONNACK, call {@code afterConnack} with the channel, + * then block until the connection is closed (or 5 s timeout). + */ + private void rawMqtt5Test(int port, int clientTopicAliasMaximum, + Consumer afterConnack) throws InterruptedException { + EventLoopGroup group = new NioEventLoopGroup(1); + CountDownLatch connackLatch = new CountDownLatch(1); + CountDownLatch closedLatch = new CountDownLatch(1); + try { + Bootstrap bootstrap = new Bootstrap() + .group(group) + .channel(NioSocketChannel.class) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ch.pipeline() + .addLast("dec", new MqttDecoder()) + .addLast("enc", MqttEncoder.INSTANCE) + .addLast("h", new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext cx, Object msg) { + // Signal when CONNACK is received so we can safely send PUBLISH + if (msg instanceof io.netty.handler.codec.mqtt.MqttConnAckMessage) { + connackLatch.countDown(); + } + } + @Override + public void channelInactive(ChannelHandlerContext cx) { + closedLatch.countDown(); + } + }); + } + }); + + ChannelFuture f = bootstrap.connect("localhost", port).sync(); + Channel ch = f.channel(); + + // Send MQTT 5 CONNECT + ch.writeAndFlush(buildMqtt5Connect(clientTopicAliasMaximum)); + + // Wait for CONNACK before sending application packets + connackLatch.await(5, TimeUnit.SECONDS); + afterConnack.accept(ch); + + // Block until the connection closes (server closes on error, or we closed it ourselves) + closedLatch.await(5, TimeUnit.SECONDS); + } finally { + group.shutdownGracefully(); + } + } + + /** Build an MQTT 5 CONNECT packet with TOPIC_ALIAS_MAXIMUM in the properties. */ + private MqttMessage buildMqtt5Connect(int topicAliasMaximum) { + MqttProperties connectProps = new MqttProperties(); + connectProps.add(new MqttProperties.IntegerProperty( + MqttProperties.MqttPropertyType.TOPIC_ALIAS_MAXIMUM.value(), topicAliasMaximum)); + + MqttConnectVariableHeader varHeader = new MqttConnectVariableHeader( + "MQTT", // protocol name + MqttVersion.MQTT_5.protocolLevel(), // 5 + false, false, // no username / password + false, 0, false, // no will + true, // clean session + 60, // keep-alive (s) + connectProps); + + MqttConnectPayload payload = new MqttConnectPayload( + "raw-alias-test-" + System.nanoTime(), + (String) null, (byte[]) null, (String) null, (byte[]) null); // no will / auth + + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.CONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0); + return MqttMessageFactory.newMessage(fixedHeader, varHeader, payload); + } + + /** + * Build a QoS-0 PUBLISH packet carrying the given topic name and TOPIC_ALIAS property. + * Pass {@code topicName = ""} to produce an alias-only packet (no topic name on the wire). + */ + private io.netty.handler.codec.mqtt.MqttPublishMessage buildPublish(String topicName, int alias) { + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(MqttProperties.MqttPropertyType.TOPIC_ALIAS.value(), alias)); + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.PUBLISH, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttPublishVariableHeader varHeader = new MqttPublishVariableHeader(topicName, 0, props); + return (io.netty.handler.codec.mqtt.MqttPublishMessage) + MqttMessageFactory.newMessage(fixedHeader, varHeader, Unpooled.EMPTY_BUFFER); + } +} diff --git a/src/test/java/io/vertx/mqtt/test/server/MqttServerBadClientTest.java b/src/test/java/io/vertx/mqtt/test/server/MqttServerBadClientTest.java index b4c82e5e..7ae88834 100644 --- a/src/test/java/io/vertx/mqtt/test/server/MqttServerBadClientTest.java +++ b/src/test/java/io/vertx/mqtt/test/server/MqttServerBadClientTest.java @@ -199,7 +199,7 @@ private MqttMessage createConnectPacket(MqttClientOptions options) { options.hasPassword(), options.isWillRetain(), options.getWillQoS(), - options.isWillFlag(), + options.getWillTopic() != null && options.getWillMessageBytes() != null, options.isCleanSession(), options.getKeepAliveInterval() ); diff --git a/src/test/java/io/vertx/mqtt/test/server/MqttServerWillTest.java b/src/test/java/io/vertx/mqtt/test/server/MqttServerWillTest.java index d7d1f470..ce3e41f2 100644 --- a/src/test/java/io/vertx/mqtt/test/server/MqttServerWillTest.java +++ b/src/test/java/io/vertx/mqtt/test/server/MqttServerWillTest.java @@ -97,7 +97,6 @@ public void testWill(TestContext context) { server.listen(context.asyncAssertSuccess(v -> { client = MqttClient.create(vertx, new MqttClientOptions() .setWillTopic("willTopic") - .setWillFlag(true) .setWillQoS(2) .setWillMessageBytes(Buffer.buffer("the-message")) ); @@ -106,80 +105,6 @@ public void testWill(TestContext context) { })); } - /** - * Test that the server rejects a CONNECT with Will Flag set but no Will Topic - * (violation of [MQTT-3.1.2-9]). The server replies with CONNACK reason - * code 0x82 (Protocol Error) and closes the connection. - */ - @Test - public void testWillMalformed(TestContext context) { - server = MqttServer.create(this.vertx, new MqttServerOptions().setHost(MQTT_SERVER_HOST).setPort(MQTT_SERVER_PORT)); - server.endpointHandler(endpoint -> context.fail("endpoint should not be reached for malformed CONNECT")); - Async async = context.async(); - server.listen(context.asyncAssertSuccess(v -> { - client = MqttClient.create(vertx, new MqttClientOptions() - .setWillFlag(true) - .setWillQoS(2) - .setWillMessageBytes(Buffer.buffer("the-message")) - ); - client.connect(MQTT_SERVER_PORT, MQTT_SERVER_HOST, ar -> { - if (ar.succeeded()) { - context.fail("connection should be rejected"); - } else { - context.assertTrue(ar.cause().getMessage().contains("CONNECTION_REFUSED_PROTOCOL_ERROR")); - } - async.complete(); - }); - })); - } - - /* - [MQTT-3.1.2-11] if Will Flag is 0, Will QoS and Will Retain MUST be 0. - (Will Topic / Will Message absence is enforced by the wire format itself, - so only Will QoS and Will Retain need explicit validation.) - */ - @Test - public void testWillParamsAbsentIfWillFlagFalseWithRetain(TestContext context) { - server = MqttServer.create(this.vertx, new MqttServerOptions().setHost(MQTT_SERVER_HOST).setPort(MQTT_SERVER_PORT)); - server.endpointHandler(endpoint -> context.fail("endpoint should not be reached for malformed CONNECT")); - Async async = context.async(); - server.listen(context.asyncAssertSuccess(v -> { - client = MqttClient.create(vertx, new MqttClientOptions() - .setWillFlag(false) - .setWillRetain(true) - ); - client.connect(MQTT_SERVER_PORT, MQTT_SERVER_HOST, ar -> { - if (ar.succeeded()) { - context.fail("connection should be rejected"); - } else { - context.assertTrue(ar.cause().getMessage().contains("CONNECTION_REFUSED_PROTOCOL_ERROR")); - } - async.complete(); - }); - })); - } - - @Test - public void testWillParamsAbsentIfWillFlagFalseWithQoS(TestContext context) { - server = MqttServer.create(this.vertx, new MqttServerOptions().setHost(MQTT_SERVER_HOST).setPort(MQTT_SERVER_PORT)); - server.endpointHandler(endpoint -> context.fail("endpoint should not be reached for malformed CONNECT")); - Async async = context.async(); - server.listen(context.asyncAssertSuccess(v -> { - client = MqttClient.create(vertx, new MqttClientOptions() - .setWillFlag(false) - .setWillQoS(1) - ); - client.connect(MQTT_SERVER_PORT, MQTT_SERVER_HOST, ar -> { - if (ar.succeeded()) { - context.fail("connection should be rejected"); - } else { - context.assertTrue(ar.cause().getMessage().contains("CONNECTION_REFUSED_PROTOCOL_ERROR")); - } - async.complete(); - }); - })); - } - @Test public void testToJson(TestContext context) { MqttProperties props1 = new MqttProperties(); From fe45fb63fe4e879f62fec34f215782a5b738854a Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Fri, 8 May 2026 09:01:20 +0200 Subject: [PATCH 2/9] Update CI --- .github/workflows/ci-client-mqtt5.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-client-mqtt5.yml b/.github/workflows/ci-client-mqtt5.yml index b904ce12..c073c8f8 100644 --- a/.github/workflows/ci-client-mqtt5.yml +++ b/.github/workflows/ci-client-mqtt5.yml @@ -1,12 +1,12 @@ -name: CI client_mqtt5_master +name: CI client_mqtt5_vertx4 on: push: branches: - - client_mqtt5_master + - client_mqtt5_vertx4 pull_request: branches: - - client_mqtt5_master + - client_mqtt5_vertx4 jobs: CI: From 669217b4ab7bb8b962caa94d375b25312479aac7 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Fri, 8 May 2026 09:01:44 +0200 Subject: [PATCH 3/9] Fix Mqtt5ServerRedirectTest / Mqtt5ClientFlowControlTest for Vert.x 4 - Mqtt5ServerRedirectTest: replace Future.compose() chain in @After with nested callback-style closes. Under Vert.x 4 the compose chain hits a RejectedExecutionException ("event executor terminated") because the intermediate future is dispatched onto a context that vertx.close() has already torn down, leaving the test framework hanging until the 120s outer timeout. Also replace Future.all (Vert.x 5) with CompositeFuture.all. - Mqtt5ClientFlowControlTest: same Future.all -> CompositeFuture.all substitution; List> tightened to List to match the Vert.x 4 CompositeFuture.all(List) overload. The redirect logic in MqttClientImpl was already correct; the failures were purely a test-side incompatibility with the Vert.x 4 future API. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mqtt/test/client/Mqtt5ClientFlowControlTest.java | 11 ++++++----- .../mqtt/test/client/Mqtt5ServerRedirectTest.java | 11 +++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java index 88340af9..5f6025d4 100644 --- a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientFlowControlTest.java @@ -19,6 +19,7 @@ import io.netty.handler.codec.mqtt.MqttProperties; import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.CompositeFuture; import io.vertx.core.Future; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; @@ -95,7 +96,7 @@ public void receiveMaximumRespected(TestContext ctx) { Future f1 = client.publish(TOPIC, Buffer.buffer("msg1"), MqttQoS.AT_LEAST_ONCE, false, false); Future f2 = client.publish(TOPIC, Buffer.buffer("msg2"), MqttQoS.AT_LEAST_ONCE, false, false); - Future.all(f1, f2).onComplete(ctx.asyncAssertSuccess(v -> { + CompositeFuture.all(f1, f2).onComplete(ctx.asyncAssertSuccess(v -> { // Third publish must fail: server Receive Maximum exceeded client.publish(TOPIC, Buffer.buffer("msg3"), MqttQoS.AT_LEAST_ONCE, false, false) .onComplete(ctx.asyncAssertFailure(err -> { @@ -136,13 +137,13 @@ public void receiveMaximumDoesNotApplyToQos0(TestContext ctx) { Future f1 = client.publish(TOPIC, Buffer.buffer("q1-1"), MqttQoS.AT_LEAST_ONCE, false, false); Future f2 = client.publish(TOPIC, Buffer.buffer("q1-2"), MqttQoS.AT_LEAST_ONCE, false, false); - Future.all(f1, f2).onComplete(ctx.asyncAssertSuccess(v -> { + CompositeFuture.all(f1, f2).onComplete(ctx.asyncAssertSuccess(v -> { // QoS 0 publishes must still succeed regardless of receive maximum - List> qos0 = new ArrayList<>(); + List qos0 = new ArrayList<>(); for (int i = 0; i < 3; i++) { qos0.add(client.publish(TOPIC, Buffer.buffer("q0-" + i), MqttQoS.AT_MOST_ONCE, false, false)); } - Future.all(qos0).onComplete(ctx.asyncAssertSuccess(v2 -> allSent.complete())); + CompositeFuture.all(qos0).onComplete(ctx.asyncAssertSuccess(v2 -> allSent.complete())); })); })); }); @@ -204,7 +205,7 @@ public void maxQos1AllowsLowerQos(TestContext ctx) { .onComplete(ctx.asyncAssertSuccess(ack -> { Future f0 = client.publish(TOPIC, Buffer.buffer("qos0"), MqttQoS.AT_MOST_ONCE, false, false); Future f1 = client.publish(TOPIC, Buffer.buffer("qos1"), MqttQoS.AT_LEAST_ONCE, false, false); - Future.all(f0, f1).onComplete(ctx.asyncAssertSuccess(v -> bothSent.complete())); + CompositeFuture.all(f0, f1).onComplete(ctx.asyncAssertSuccess(v -> bothSent.complete())); })); }); diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java index cf661e45..8fe44efa 100644 --- a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ServerRedirectTest.java @@ -19,7 +19,7 @@ import io.netty.handler.codec.mqtt.MqttConnectReturnCode; import io.netty.handler.codec.mqtt.MqttProperties; import io.netty.handler.codec.mqtt.MqttVersion; -import io.vertx.core.Future; +import io.vertx.core.CompositeFuture; import io.vertx.core.Vertx; import io.vertx.ext.unit.Async; import io.vertx.ext.unit.TestContext; @@ -60,10 +60,9 @@ public void before() { @After public void after(TestContext ctx) { - server1.close() - .compose(v -> server2.close()) - .compose(v -> vertx.close()) - .onComplete(ctx.asyncAssertSuccess()); + server1.close(ctx.asyncAssertSuccess(v1 -> + server2.close(ctx.asyncAssertSuccess(v2 -> + vertx.close(ctx.asyncAssertSuccess()))))); } // ------------------------------------------------------------------------- @@ -180,7 +179,7 @@ public void connackRedirectFromList(TestContext ctx) { server3.endpointHandler(ep -> { ep.accept(false); done.complete(); }); // Start server2 and server3 in parallel, then server1 - Future.all(server2.listen(0), server3.listen(0)) + CompositeFuture.all(server2.listen(0), server3.listen(0)) .onComplete(ctx.asyncAssertSuccess(v -> { server1.endpointHandler(ep -> { MqttProperties props = new MqttProperties(); From 5006b19fd04ba29dff16463963487d236f8fa3a8 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Mon, 11 May 2026 18:22:12 +0200 Subject: [PATCH 4/9] Enhance documentation for MQTT client to include support for MQTT 5.0 features --- src/main/asciidoc/index.adoc | 386 ++++++++++++++++++++++++++++++++++- 1 file changed, 385 insertions(+), 1 deletion(-) diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index f2d0a7ac..5caed66e 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -253,8 +253,10 @@ them in a round-robin fashion to any of the connect handlers executed on differe = Vert.x MQTT client -This component provides an link:http://mqtt.org/[MQTT] client which is compliant with the 3.1.1 spec. Its API provides a bunch of methods +This component provides an link:http://mqtt.org/[MQTT] client which is compliant with the 3.1.1 and 5.0 specs. Its API provides a bunch of methods for connecting/disconnecting to a broker, publishing messages (with all three different levels of QoS) and subscribing to topics. +MQTT 5.0 features such as user properties, reason codes, subscription options, subscription identifiers, topic aliases and +automatic server redirect are supported in addition to the 3.1.1 baseline. WARNING: this module has the tech preview status, this means the API can change between versions. @@ -390,3 +392,385 @@ IMPORTANT: to enable this feature, you need to add dependency `netty-codec-hapro ---- compile io.netty:netty-codec-haproxy:${maven.version} ---- + +== MQTT 5.0 client features + +The client targets MQTT 3.1.1 by default. To switch the wire protocol to MQTT 5.0, set the protocol version on +{@link io.vertx.mqtt.MqttClientOptions} before creating the client: + +[source,java] +---- +MqttClientOptions options = new MqttClientOptions(); +options.setVersion(io.netty.handler.codec.mqtt.MqttVersion.MQTT_5.protocolLevel()); // 5 +MqttClient client = MqttClient.create(vertx, options); +---- + +Only the values `4` (MQTT 3.1.1) and `5` (MQTT 5.0) are accepted by +{@link io.vertx.mqtt.MqttClientOptions#setVersion(int)}. + +=== CONNECT properties + +When using MQTT 5.0, the following CONNECT properties can be configured on {@link io.vertx.mqtt.MqttClientOptions}: + +* {@link io.vertx.mqtt.MqttClientOptions#setSessionExpireInterval(java.lang.Long)} — Session Expiry Interval (seconds) +* {@link io.vertx.mqtt.MqttClientOptions#setReceiveMaximum(java.lang.Integer)} — maximum number of in-flight QoS 1/2 PUBLISH packets the client is willing to receive +* {@link io.vertx.mqtt.MqttClientOptions#setMaximumPacketSize(java.lang.Long)} — maximum packet size the client will accept from the server +* {@link io.vertx.mqtt.MqttClientOptions#setTopicAliasMaximum(java.lang.Integer)} — highest topic alias value the client will accept from the server +* {@link io.vertx.mqtt.MqttClientOptions#setRequestResponseInformation(java.lang.Boolean)} / {@link io.vertx.mqtt.MqttClientOptions#setRequestProblemInformation(java.lang.Boolean)} +* {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationMethod(java.lang.String)} / {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationData(io.vertx.core.buffer.Buffer)} + +User properties can be attached to the CONNECT packet by using the +{@link io.vertx.mqtt.MqttClient#connect(int, java.lang.String, java.lang.String, java.util.Map)} overload that takes a `Map` of user properties. + +The result of the CONNECT is delivered as a {@link io.vertx.mqtt.messages.MqttConnAckMessage} which, on MQTT 5.0, exposes +the full set of server-advertised properties (Receive Maximum, Maximum QoS, Retain Available, Maximum Packet Size, +Assigned Client Identifier, Topic Alias Maximum, Reason String, Wildcard / Shared Subscription Available, Subscription +Identifiers Available, Server Keep Alive, Response Information, Server Reference, Authentication Method/Data and +user properties). + +==== Session Expiry Interval + +The MQTT 5.0 Session Expiry Interval (in seconds) replaces the 3.1.1 _clean session_ flag and tells the broker how long +session state (subscriptions, queued QoS 1/2 messages) must survive after the network connection is closed. It is set on +{@link io.vertx.mqtt.MqttClientOptions} before connecting: + +[source,java] +---- +MqttClientOptions options = new MqttClientOptions(); +options.setVersion(io.netty.handler.codec.mqtt.MqttVersion.MQTT_5.protocolLevel()); +options.setCleanSession(false); // required to keep a persistent session +options.setSessionExpireInterval(3600L); // keep the session for one hour after disconnect +MqttClient client = MqttClient.create(vertx, options); +---- + +Semantics follow MQTT 5.0 §3.1.2.11: + +* `null` (the default) or `0L` — the session ends as soon as the network connection ends (equivalent to a clean session). +* a positive value — the session persists for that many seconds after the connection is closed. +* `0xFFFFFFFFL` (`4_294_967_295L`) — the session never expires. + +Valid values are in the range `0L..0xFFFFFFFFL`; values outside this range cause +{@link io.vertx.mqtt.MqttClientOptions#setSessionExpireInterval(java.lang.Long)} to throw `IllegalArgumentException`. +The property is only put on the wire when the protocol version is `5`; on MQTT 3.1.1 the value is silently ignored and +session persistence is driven exclusively by {@link io.vertx.mqtt.MqttClientOptions#setCleanSession(boolean)}. + +The broker may override the requested value: when present, the Session Expiry Interval returned in the CONNACK is the +authoritative one for the lifetime of the session and can be inspected via +{@link io.vertx.mqtt.messages.MqttConnAckMessage#sessionExpiryInterval()}. + +==== Receive Maximum + +Receive Maximum (MQTT 5.0 §3.1.2.11.3) is the per-direction flow-control window for QoS 1 and QoS 2 PUBLISH packets: +it is the maximum number of in-flight messages (sent but not yet fully acknowledged) the peer is willing to accept. +Each side advertises its own limit and must honour the limit advertised by the other. + +*Client side* — set on {@link io.vertx.mqtt.MqttClientOptions} before connecting: + +[source,java] +---- +MqttClientOptions options = new MqttClientOptions(); +options.setVersion(io.netty.handler.codec.mqtt.MqttVersion.MQTT_5.protocolLevel()); +options.setReceiveMaximum(20); // accept at most 20 concurrent in-flight QoS 1/2 PUBLISHes from the server +MqttClient client = MqttClient.create(vertx, options); +---- + +* The value is a positive 16-bit integer; valid range is `1..65535`. Values outside `0..0xFFFF` are rejected by + {@link io.vertx.mqtt.MqttClientOptions#setReceiveMaximum(java.lang.Integer)} with `IllegalArgumentException`. +* `null` (the default) omits the property from the CONNECT, which per spec means "no limit" (`65535`). +* The property is only put on the wire when the protocol version is `5`; on MQTT 3.1.1 the value is ignored. + +*Server side* — the value the broker advertises in its CONNACK is enforced automatically when calling +{@link io.vertx.mqtt.MqttClient#publish(java.lang.String, io.vertx.core.buffer.Buffer, io.netty.handler.codec.mqtt.MqttQoS, boolean, boolean)} +for QoS 1 or 2: + +* if the number of in-flight messages already equals the server's Receive Maximum, the returned `Future` fails with + an {@link io.vertx.mqtt.MqttException} whose code is `MQTT_INFLIGHT_QUEUE_FULL`; +* QoS 0 PUBLISH packets are never gated by this limit (they have no acknowledgement); +* if the CONNACK omits the property, the client treats it as unlimited (`Integer.MAX_VALUE` internally); +* the negotiated value can be inspected via {@link io.vertx.mqtt.messages.MqttConnAckMessage#receiveMaximum()}. + +NOTE: this is independent from (and applied in addition to) the existing client-local cap configured via +{@link io.vertx.mqtt.MqttClientOptions#setMaxInflightQueue(int)}; whichever limit is hit first triggers the +`MQTT_INFLIGHT_QUEUE_FULL` failure. + +=== Last Will and Testament (LWT) + +Will message configuration has been moved to a dedicated {@link io.vertx.mqtt.MqttClientWillOptions} object that supports +both 3.1.1 fields (topic, payload, QoS, retain) and the MQTT 5.0 will properties: Will Delay Interval, +Payload Format Indicator, Content Type, Response Topic, Correlation Data and user properties. The will options can be +set via {@link io.vertx.mqtt.MqttClientOptions#setWillOptions(io.vertx.mqtt.MqttClientWillOptions)}; the legacy +`setWillTopic` / `setWillMessage` / `setWillQoS` / `setWillRetain` setters are still available for 3.1.1 compatibility. + +IMPORTANT: The standalone `willFlag` setter has been removed on the client side — the presence of a will is now derived +from `willTopic` and `willPayload`. In JSON, the will is serialized as a nested `willOptions` object. + +=== Publishing with properties + +When using MQTT 5.0, outgoing PUBLISH packets can carry additional properties (Payload Format Indicator, Message Expiry +Interval, Content Type, Response Topic, Correlation Data, user properties, Topic Alias). Use +{@link io.vertx.mqtt.MqttClient#publish(java.lang.String, io.vertx.core.buffer.Buffer, io.netty.handler.codec.mqtt.MqttQoS, boolean, boolean, io.netty.handler.codec.mqtt.MqttProperties)} +to pass an `MqttProperties` instance alongside the payload. The Subscription Identifier property is _not_ valid on a +client-originated PUBLISH — see the dedicated section below. + +The acknowledgement flow now exposes the full typed messages including reason code and properties: + +* {@link io.vertx.mqtt.MqttClient#publishAckMessageHandler(io.vertx.core.Handler)} — PUBACK (QoS 1) +* {@link io.vertx.mqtt.MqttClient#publishRecMessageHandler(io.vertx.core.Handler)} — PUBREC (QoS 2) +* {@link io.vertx.mqtt.MqttClient#publishCompMessageHandler(io.vertx.core.Handler)} — PUBCOMP (QoS 2) + +These fire alongside the existing +{@link io.vertx.mqtt.MqttClient#publishCompletionHandler(io.vertx.core.Handler)} so that 3.1.1 code keeps working. +On the inbound side, the client can also acknowledge an incoming PUBLISH with reason code and properties via +{@link io.vertx.mqtt.MqttClient#publishAcknowledge(int, io.vertx.mqtt.messages.codes.MqttPubAckReasonCode, io.netty.handler.codec.mqtt.MqttProperties)}, +{@link io.vertx.mqtt.MqttClient#publishReceived(int, io.vertx.mqtt.messages.codes.MqttPubRecReasonCode, io.netty.handler.codec.mqtt.MqttProperties)}, +{@link io.vertx.mqtt.MqttClient#publishRelease(int, io.vertx.mqtt.messages.codes.MqttPubRelReasonCode, io.netty.handler.codec.mqtt.MqttProperties)} and +{@link io.vertx.mqtt.MqttClient#publishComplete(int, io.vertx.mqtt.messages.codes.MqttPubCompReasonCode, io.netty.handler.codec.mqtt.MqttProperties)}. + +=== Subscriptions + +Two MQTT 5.0 specific overloads of `subscribe` are available: + +* {@link io.vertx.mqtt.MqttClient#subscribe(java.util.Map, io.netty.handler.codec.mqtt.MqttProperties)} — same QoS map as the 3.1.1 API plus a properties object (for example a Subscription Identifier). +* {@link io.vertx.mqtt.MqttClient#subscribe(java.util.List, io.netty.handler.codec.mqtt.MqttProperties)} — for fine-grained subscription options. Each `MqttTopicSubscription` carries an `MqttSubscriptionOption` that encodes QoS plus the v5 flags (`No Local`, `Retain As Published`, `Retain Handling`). + +`unsubscribe` has a matching {@link io.vertx.mqtt.MqttClient#unsubscribe(java.util.List, io.netty.handler.codec.mqtt.MqttProperties)} overload +that accepts properties. The SUBACK and UNSUBACK responses expose per-topic reason codes and properties via +{@link io.vertx.mqtt.messages.MqttSubAckMessage} and {@link io.vertx.mqtt.messages.MqttUnsubAckMessage}; for UNSUBACK +the dedicated {@link io.vertx.mqtt.MqttClient#unsubscribeCompletionMessageHandler(io.vertx.core.Handler)} delivers the +full typed message. + +=== Subscription Identifier + +The Subscription Identifier (MQTT 5.0 §3.8.2.1.2) is a positive integer that the client attaches to a SUBSCRIBE request; +the broker then echoes it back on every PUBLISH that matches that subscription (§3.3.2.3.8), so the client can route +incoming messages to the correct handler without re-parsing the topic. + +It is set as a property on the SUBSCRIBE packet, not on the PUBLISH: + +[source,java] +---- +MqttProperties subProps = new MqttProperties(); +subProps.add(new MqttProperties.IntegerProperty( + MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value(), + 42)); + +client.subscribe(java.util.Collections.singletonMap("sensors/+/temperature", 1), subProps); +---- + +The same `subProps` can be passed to the list-based overload +{@link io.vertx.mqtt.MqttClient#subscribe(java.util.List, io.netty.handler.codec.mqtt.MqttProperties)} when you need to +combine the identifier with per-topic v5 subscription options (No Local, Retain As Published, Retain Handling). + +On the receiving side, read the identifier from the incoming PUBLISH: + +[source,java] +---- +client.publishHandler(msg -> { + MqttProperties.MqttProperty p = msg.properties() + .getProperty(MqttProperties.MqttPropertyType.SUBSCRIPTION_IDENTIFIER.value()); + if (p != null) { + int id = (Integer) p.value(); // 42 + // route based on id + } +}); +---- + +If the broker matches the PUBLISH against more than one subscription, the property is repeated — use +`properties().getProperties(SUBSCRIPTION_IDENTIFIER.value())` to obtain the full list. + +Preconditions enforced by the client (an attempt to subscribe with a Subscription Identifier that violates them fails +the returned `Future` with {@link io.vertx.mqtt.MqttException}): + +* the protocol version must be `5` (`MqttClientOptions.setVersion(5)`) — otherwise `MQTT_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED`; +* the broker must not have advertised `SUBSCRIPTION_IDENTIFIER_AVAILABLE=0` in the CONNACK; the negotiated value is + exposed by {@link io.vertx.mqtt.messages.MqttConnAckMessage#subscriptionIdentifierAvailable()} and, when missing, + defaults to "available" per spec. + +=== Topic alias + +Topic aliases (MQTT 5.0 §3.3.2.3.4) are managed automatically in both directions. + +*Server → client:* when an incoming PUBLISH carries a Topic Alias property, the alias-to-topic mapping is cached and +subsequent packets that reuse the same alias with an empty topic name are transparently re-expanded before being delivered +to the {@link io.vertx.mqtt.MqttClient#publishHandler(io.vertx.core.Handler)}. The maximum number of aliases the broker +may use is controlled by {@link io.vertx.mqtt.MqttClientOptions#setTopicAliasMaximum(java.lang.Integer)}; an alias value +of 0 or one above the negotiated maximum results in a DISCONNECT with reason code `TOPIC_ALIAS_INVALID`. + +*Client → server:* when the broker advertises a non-zero `Topic Alias Maximum` in its CONNACK, the client transparently +assigns aliases to outgoing PUBLISH packets. The first PUBLISH for a given topic carries the full topic name plus a newly +allocated Topic Alias property; subsequent PUBLISH packets on the same topic are sent with an empty topic name and just +the alias. If the alias pool advertised by the broker is exhausted, further topics are published with their full name +and no alias. This is fully internal — applications keep calling +{@link io.vertx.mqtt.MqttClient#publish(java.lang.String, io.vertx.core.buffer.Buffer, io.netty.handler.codec.mqtt.MqttQoS, boolean, boolean)} +with the real topic name and pay no attention to aliases. + +=== Request / Response interaction + +MQTT 5.0 §4.10 standardises a request/response pattern on top of regular PUBLISH packets, using three new properties: +*Response Topic*, *Correlation Data* and the optional *Response Information* hint the broker may advertise to help +the client pick a topic prefix. The pattern is fully supported in this client. + +==== Step 1 — (optional) ask the broker for Response Information + +If you want the broker to suggest a topic prefix to use as the Response Topic (typically a unique per-client path that +is already covered by the broker's ACL for that client), set `Request Response Information = true` on the CONNECT: + +[source,java] +---- +MqttClientOptions options = new MqttClientOptions(); +options.setVersion(io.netty.handler.codec.mqtt.MqttVersion.MQTT_5.protocolLevel()); +options.setRequestResponseInformation(true); +MqttClient client = MqttClient.create(vertx, options); + +client.connect(1883, "broker.example.com") + .onSuccess(connack -> { + String prefix = connack.responseInformation(); // null if the broker didn't return any + // e.g. "$share/responses/{clientId}/" + }); +---- + +See {@link io.vertx.mqtt.MqttClientOptions#setRequestResponseInformation(java.lang.Boolean)} and +{@link io.vertx.mqtt.messages.MqttConnAckMessage#responseInformation()}. The broker is free to ignore the request — +treat `null` as "no hint, pick your own response topic". + +==== Step 2 — requester: PUBLISH with Response Topic + Correlation Data + +The requester subscribes to whatever topic it will use to receive replies, then publishes the request with the two +properties set: + +[source,java] +---- +// 1. subscribe to the reply channel +String replyTopic = "clients/" + client.clientId() + "/replies"; +client.subscribe(replyTopic, 1); + +// 2. publish the request +byte[] correlationId = java.util.UUID.randomUUID().toString().getBytes(); + +MqttProperties reqProps = new MqttProperties(); +reqProps.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value(), replyTopic)); +reqProps.add(new MqttProperties.BinaryProperty( + MqttProperties.MqttPropertyType.CORRELATION_DATA.value(), correlationId)); + +client.publish("requests/temperature", + Buffer.buffer("{\"room\":\"kitchen\"}"), + MqttQoS.AT_LEAST_ONCE, false, false, reqProps); +---- + +Correlation Data is opaque to MQTT — its only purpose is to let the requester pair a reply back to the originating +request when several requests are outstanding at the same time. + +==== Step 3 — responder: read Response Topic + Correlation Data, publish back + +On the responder side the two properties arrive on the incoming PUBLISH: + +[source,java] +---- +client.publishHandler(msg -> { + if (!"requests/temperature".equals(msg.topicName())) return; + + String respTopic = ((MqttProperties.StringProperty) msg.properties() + .getProperty(MqttProperties.MqttPropertyType.RESPONSE_TOPIC.value())).value(); + byte[] corr = ((MqttProperties.BinaryProperty) msg.properties() + .getProperty(MqttProperties.MqttPropertyType.CORRELATION_DATA.value())).value(); + + MqttProperties respProps = new MqttProperties(); + respProps.add(new MqttProperties.BinaryProperty( + MqttProperties.MqttPropertyType.CORRELATION_DATA.value(), corr)); + + client.publish(respTopic, + Buffer.buffer("{\"temp\":21.5}"), + MqttQoS.AT_LEAST_ONCE, false, false, respProps); +}); +---- + +A responder that cannot or will not honour the request (missing Response Topic, unsupported payload, etc.) simply +does not publish a reply — there is no protocol-level negative ack defined for this pattern. + +==== Step 4 — requester: correlate the reply + +Back on the requester, the same `publishHandler` callback receives the reply on `replyTopic`. Match the Correlation +Data against the value used in the request to route the response to the right caller (e.g. complete a +`Promise` kept in a `Map>`). + +==== Last Will and request/response + +A will message can also act as a "I'm gone" reply: {@link io.vertx.mqtt.MqttClientWillOptions} exposes +`setResponseTopic(...)` and `setCorrelationData(...)` so the broker, on abnormal disconnect, will publish a will +that any pending requester can correlate. + +=== Server-initiated DISCONNECT + +A handler can be registered to be notified when the server sends a DISCONNECT packet (rather than the client closing the +connection itself): + +[source,java] +---- +client.disconnectMessageHandler(msg -> { + System.out.println("server disconnected, reason=" + msg.code() + " props=" + msg.properties()); +}); +---- + +See {@link io.vertx.mqtt.MqttClient#disconnectMessageHandler(io.vertx.core.Handler)}. The handler fires before +{@link io.vertx.mqtt.MqttClient#closeHandler(io.vertx.core.Handler)} and only for server-initiated disconnects. +The client can also actively send a DISCONNECT with reason code and properties via +{@link io.vertx.mqtt.MqttClient#disconnect(io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode, io.netty.handler.codec.mqtt.MqttProperties)}. + +=== Enhanced Authentication (AUTH) + +MQTT 5.0 §4.12 introduces an _Enhanced Authentication_ flow built on a new AUTH control packet, designed to support +multi-round challenge/response schemes (e.g. SCRAM, Kerberos, mutual proofs) that cannot fit in the single CONNECT +exchange used by 3.1.1. + +==== What is supported today + +The client can advertise an authentication method and provide the first chunk of authentication data inside the CONNECT +packet, which is the initial step of any enhanced authentication exchange: + +[source,java] +---- +MqttClientOptions options = new MqttClientOptions(); +options.setVersion(io.netty.handler.codec.mqtt.MqttVersion.MQTT_5.protocolLevel()); +options.setAuthenticationMethod("SCRAM-SHA-256"); +options.setAuthenticationData(io.vertx.core.buffer.Buffer.buffer(clientFirstMessage)); +MqttClient client = MqttClient.create(vertx, options); +---- + +Setters available on {@link io.vertx.mqtt.MqttClientOptions}: + +* {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationMethod(java.lang.String)} — UTF-8 name of the + authentication mechanism (MQTT 5.0 §3.1.2.11.9). When set, the broker is expected to use the AUTH packet + for the subsequent rounds and to fail the connection with `Bad authentication method` if it does not support it. +* {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationData(io.vertx.core.buffer.Buffer)} — opaque binary + payload required by the chosen method (MQTT 5.0 §3.1.2.11.10). + +The values returned by the broker in the CONNACK can be inspected on the resulting +{@link io.vertx.mqtt.messages.MqttConnAckMessage} via `authenticationMethod()` and `authenticationData()`. + +==== What is not yet supported + +The full AUTH-exchange API (the additional control-packet round trips that follow the CONNECT) is **not yet ported to +the Vert.x 4 branch**: + +* `MqttClient` does not expose a method to send an AUTH packet, nor a handler to be notified when the broker sends one. +* On the server side, {@link io.vertx.mqtt.impl.MqttEndpointImpl} accepts incoming AUTH packets at the wire level + (so a broker can be probed without dropping the TCP connection) but {@code handleAuth} is a stub that does not surface + the AUTH packet to user code, and there is no API to reply with a server-originated AUTH. + +In practice this means that on this branch only *single-step* enhanced authentication works — i.e. mechanisms where the +data sent on CONNECT is sufficient and the broker can answer with a successful CONNACK without further AUTH round trips. +Multi-step mechanisms and post-connect re-authentication require porting upstream commit `892e923`, which introduces +the public {@code MqttAuthenticationExchangeMessage} API (`MqttClient#authenticationExchangeHandler` / a `sendAuth` +counterpart and the matching `MqttEndpoint` hooks). + +The transitive types ({@link io.vertx.mqtt.messages.MqttAuthenticationExchangeMessage} and +{@link io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode}) are already present in this branch so that the +public API surface does not change when that port lands. + +=== Automatic server redirect + +When the broker returns a CONNACK or DISCONNECT with a `Server Reference` property (MQTT 5.0 §3.2.2.3.18 / §3.14.2.3.4), +the client can transparently reconnect to one of the servers listed there. This behavior is enabled by default and can be +toggled with {@link io.vertx.mqtt.MqttClientOptions#setAutoServerRedirect(boolean)}. When several references are present +in the comma-separated list, one is picked at random. From 3a67057be4b0888efa14be587fe76079a1bc0350 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Thu, 21 May 2026 16:20:24 +0200 Subject: [PATCH 5/9] Refactor MqttClientOptions and MqttClientWillOptions setters to return current instance and add validation for QoS --- .github/workflows/ci-client-mqtt5.yml | 26 ------------------- .../java/io/vertx/mqtt/MqttClientOptions.java | 14 +++++----- .../io/vertx/mqtt/MqttClientWillOptions.java | 3 +++ 3 files changed, 11 insertions(+), 32 deletions(-) delete mode 100644 .github/workflows/ci-client-mqtt5.yml diff --git a/.github/workflows/ci-client-mqtt5.yml b/.github/workflows/ci-client-mqtt5.yml deleted file mode 100644 index c073c8f8..00000000 --- a/.github/workflows/ci-client-mqtt5.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: CI client_mqtt5_vertx4 - -on: - push: - branches: - - client_mqtt5_vertx4 - pull_request: - branches: - - client_mqtt5_vertx4 - -jobs: - CI: - name: Run tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.ref }} - - name: Install JDK - uses: actions/setup-java@v4 - with: - java-version: 21 - distribution: temurin - - name: Run tests - run: mvn -s .github/maven-ci-settings.xml -q clean verify -B diff --git a/src/main/java/io/vertx/mqtt/MqttClientOptions.java b/src/main/java/io/vertx/mqtt/MqttClientOptions.java index f9d285b8..25dedadf 100644 --- a/src/main/java/io/vertx/mqtt/MqttClientOptions.java +++ b/src/main/java/io/vertx/mqtt/MqttClientOptions.java @@ -490,10 +490,12 @@ public boolean isAutoAck() { * If true, the ack (PUBACK/PUBCOMP) will be sent by vertx-mqtt before {@link MqttClient#publishHandler()} execution. * (default is true) * - * @param autoAck + * @param autoAck if the ack (PUBACK/PUBCOMP) will be sent automatically by vertx-mqtt + * @return current options instance */ - public void setAutoAck(boolean autoAck) { + public MqttClientOptions setAutoAck(boolean autoAck) { this.autoAck = autoAck; + return this; } /** @@ -620,8 +622,8 @@ public Long getMaximumPacketSize() { } public void setMaximumPacketSize(Long maximumPacketSize) { - if (sessionExpireInterval != null && - (sessionExpireInterval < 0L || sessionExpireInterval > 0xFFFFFFFFL)) { + if (maximumPacketSize != null && + (maximumPacketSize < 0L || maximumPacketSize > 0xFFFFFFFFL)) { throw new IllegalArgumentException("Invalid Maximum Packet Size"); } this.maximumPacketSize = maximumPacketSize; @@ -632,8 +634,8 @@ public Integer getTopicAliasMaximum() { } public void setTopicAliasMaximum(Integer topicAliasMaximum) { - if (receiveMaximum != null && - (receiveMaximum < 0L || receiveMaximum > 0xFFFFL)) { + if (topicAliasMaximum != null && + (topicAliasMaximum < 0L || topicAliasMaximum > 0xFFFFL)) { throw new IllegalArgumentException("Invalid Topic Alias Maximum"); } this.topicAliasMaximum = topicAliasMaximum; diff --git a/src/main/java/io/vertx/mqtt/MqttClientWillOptions.java b/src/main/java/io/vertx/mqtt/MqttClientWillOptions.java index 070c4db7..7eba9aa9 100644 --- a/src/main/java/io/vertx/mqtt/MqttClientWillOptions.java +++ b/src/main/java/io/vertx/mqtt/MqttClientWillOptions.java @@ -147,6 +147,9 @@ public int getQos() { * @return this options instance */ public MqttClientWillOptions setQos(int qos) { + if (qos < 0 || qos > 2) { + throw new IllegalArgumentException("QoS must be 0, 1, or 2"); + } this.qos = qos; return this; } From 5ed3231ee0597fb1574aab1f4c566768c9df8678 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Thu, 21 May 2026 16:20:43 +0200 Subject: [PATCH 6/9] Implement MQTT 5.0 AUTH packet handling in MqttClient and MqttClientImpl, and add corresponding tests --- src/main/java/io/vertx/mqtt/MqttClient.java | 33 ++- .../io/vertx/mqtt/impl/MqttClientImpl.java | 60 +++++- .../mqtt/test/client/Mqtt5ClientAuthTest.java | 199 ++++++++++++++++++ 3 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java diff --git a/src/main/java/io/vertx/mqtt/MqttClient.java b/src/main/java/io/vertx/mqtt/MqttClient.java index b10e10d6..efd22c26 100644 --- a/src/main/java/io/vertx/mqtt/MqttClient.java +++ b/src/main/java/io/vertx/mqtt/MqttClient.java @@ -17,6 +17,7 @@ package io.vertx.mqtt; import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttProperties; import io.netty.handler.codec.mqtt.MqttSubscriptionOption; import io.netty.handler.codec.mqtt.MqttTopicSubscription; import io.vertx.codegen.annotations.Fluent; @@ -28,6 +29,7 @@ import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.mqtt.impl.MqttClientImpl; +import io.vertx.mqtt.messages.MqttAuthenticationExchangeMessage; import io.vertx.mqtt.messages.MqttConnAckMessage; import io.vertx.mqtt.messages.MqttDisconnectMessage; import io.vertx.mqtt.messages.MqttPubAckMessage; @@ -36,7 +38,7 @@ import io.vertx.mqtt.messages.MqttPublishMessage; import io.vertx.mqtt.messages.MqttSubAckMessage; import io.vertx.mqtt.messages.MqttUnsubAckMessage; -import io.netty.handler.codec.mqtt.MqttProperties; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; import io.vertx.mqtt.messages.codes.MqttDisconnectReasonCode; import io.vertx.mqtt.messages.codes.MqttPubAckReasonCode; import io.vertx.mqtt.messages.codes.MqttPubRecReasonCode; @@ -491,6 +493,35 @@ static MqttClient create(Vertx vertx) { @GenIgnore(GenIgnore.PERMITTED_TYPE) MqttClient disconnectMessageHandler(Handler handler); + /** + * Sets a handler that will be called when the server sends an AUTH packet + * (MQTT 5.0 Enhanced Authentication, see §3.15). + *

+ * The handler receives the reason code, the authentication method, the + * authentication data and the full set of MQTT properties from the server's + * AUTH packet. The user can then reply with {@link #authenticationExchange}. + * + * @param handler handler to call with the AUTH message + * @return current MQTT client instance + */ + @Fluent + @GenIgnore(GenIgnore.PERMITTED_TYPE) + MqttClient authenticationExchangeHandler(Handler handler); + + /** + * Send an AUTH packet to the server. + *

+ * Used to continue an Enhanced Authentication exchange started in CONNECT, + * or to request re-authentication on an already-established session. + * Available only when the client is configured for MQTT 5.0. + * + * @param reasonCode authenticate reason code + * @param properties MQTT properties (typically AUTHENTICATION_METHOD and AUTHENTICATION_DATA) + * @return a {@code Future} completed when the packet has been written + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + Future authenticationExchange(MqttAuthenticateReasonCode reasonCode, MqttProperties properties); + /** * Set a handler that will be called when the connection with server is closed * diff --git a/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java b/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java index 608e3377..10d1f59f 100644 --- a/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java +++ b/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java @@ -490,6 +490,33 @@ public MqttClient disconnect(Handler> disconnectHandler) { return this; } + /** + * See {@link MqttClient#authenticationExchange(MqttAuthenticateReasonCode, MqttProperties)} for more details + */ + @Override + public Future authenticationExchange(MqttAuthenticateReasonCode reasonCode, MqttProperties properties) { + + if (options.getVersion() != 5) { + return Future.failedFuture(new IllegalStateException("AUTH packet requires MQTT 5.0")); + } + + synchronized (this) { + if (this.status != Status.CONNECTED) { + return Future.failedFuture(new IllegalStateException("Client not connected")); + } + } + + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.AUTH, false, AT_MOST_ONCE, false, 0); + MqttReasonCodeAndPropertiesVariableHeader variableHeader = + new MqttReasonCodeAndPropertiesVariableHeader( + reasonCode == null ? MqttAuthenticateReasonCode.SUCCESS.value() : reasonCode.value(), + properties == null ? MqttProperties.NO_PROPERTIES : properties); + io.netty.handler.codec.mqtt.MqttMessage auth = + MqttMessageFactory.newMessage(fixedHeader, variableHeader, null); + return this.write(auth); + } + /** * See {@link MqttClient#publish(String, Buffer, MqttQoS, boolean, boolean)} for more details */ @@ -1052,6 +1079,16 @@ private synchronized Handler disconnectMessageHandler() { return this.disconnectMessageHandler; } + @Override + public synchronized MqttClient authenticationExchangeHandler(Handler handler) { + this.authenticationExchangeHandler = handler; + return this; + } + + private synchronized Handler authenticationExchangeHandler() { + return this.authenticationExchangeHandler; + } + private class Ping { final long id; private Ping(long id) { @@ -1582,6 +1619,23 @@ private void handleMessage(ChannelHandlerContext chctx, Object msg) { } break; + case AUTH: + // MQTT 5.0: server-sent AUTH (Enhanced Authentication §3.15) + if (options.getVersion() == 5 + && mqttMessage.variableHeader() instanceof MqttReasonCodeAndPropertiesVariableHeader) { + MqttReasonCodeAndPropertiesVariableHeader authVarHeader = + (MqttReasonCodeAndPropertiesVariableHeader) mqttMessage.variableHeader(); + MqttAuthenticateReasonCode authReasonCode = + MqttAuthenticateReasonCode.valueOf((byte) authVarHeader.reasonCode()); + MqttAuthenticationExchangeMessage authMsg = + MqttAuthenticationExchangeMessage.create(authReasonCode, authVarHeader.properties()); + Handler authHandler = this.authenticationExchangeHandler(); + if (authHandler != null) { + authHandler.handle(authMsg); + } + } + break; + default: chctx.pipeline().fireExceptionCaught(new Exception("Wrong message type " + msg.getClass().getName())); @@ -1692,8 +1746,8 @@ private void handlePubackTimeout(int packetId) { log.debug("PUBLISH expiration timer fired but QoS 1 message has already been PUBACKed by server"); return; } + countInflightQueue--; } - countInflightQueue--; Handler handler = publishCompletionExpirationHandler(); if (handler != null) { handler.handle(expiredMessage.packetId); @@ -1742,8 +1796,8 @@ private void handlePubcompTimeout(int packetId) { log.debug("PUBCOMP expiration timer fired but QoS 2 message has already been PUBCOMPed by server"); return; } + countInflightQueue--; } - countInflightQueue--; Handler handler = publishCompletionExpirationHandler(); if (handler != null) { handler.handle(expiredMessage.packetId); @@ -1788,8 +1842,8 @@ private void handlePubrecTimeout(int packetId) { log.debug("PUBREC expiration timer fired but QoS 2 message has already been PUBRECed by server"); return; } + countInflightQueue--; } - countInflightQueue--; Handler handler = publishCompletionExpirationHandler(); if (handler != null) { handler.handle(expiredMessage.packetId); diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java new file mode 100644 index 00000000..eb0ed0f5 --- /dev/null +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.vertx.mqtt.test.client; + +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageFactory; +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttReasonCodeAndPropertiesVariableHeader; +import io.netty.handler.codec.mqtt.MqttVersion; +import io.vertx.core.Vertx; +import io.vertx.core.net.impl.NetSocketInternal; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.messages.MqttAuthenticationExchangeMessage; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Tests for MQTT 5.0 AUTH packet handling on the client side + * (Enhanced Authentication, MQTT 5.0 §3.15). + * + * Covers: + * 1. server-sent AUTH → registered handler is invoked with the right + * reason code, authentication method and authentication data + * 2. authenticationExchange() with a non-v5 client returns a failed Future + * 3. authenticationExchange() on a not-connected client returns a failed Future + */ +@RunWith(VertxUnitRunner.class) +public class Mqtt5ClientAuthTest { + + private Vertx vertx; + private MqttServer server; + + @Before + public void before() { + vertx = Vertx.vertx(); + server = MqttServer.create(vertx); + } + + @After + public void after(TestContext ctx) { + server.close().onComplete(ctx.asyncAssertSuccess(v -> + vertx.close().onComplete(ctx.asyncAssertSuccess()))); + } + + // ------------------------------------------------------------------------- + + /** + * The server accepts the connection then sends an AUTH packet + * (CONTINUE_AUTHENTICATION, with method "SCRAM-SHA-1" and a challenge in the + * authentication data). The client's authenticationExchangeHandler must be + * invoked with these exact values. + */ + @Test + public void clientReceivesAuthFromServer(TestContext ctx) { + Async done = ctx.async(); + + final String expectedMethod = "SCRAM-SHA-1"; + final byte[] expectedChallenge = "challenge-bytes".getBytes(); + + server.endpointHandler(ep -> { + ep.accept(false); + // After CONNACK is flushed, push an AUTH packet on the same channel. + vertx.runOnContext(v -> { + try { + Field connField = ep.getClass().getDeclaredField("conn"); + connField.setAccessible(true); + NetSocketInternal conn = (NetSocketInternal) connField.get(ep); + + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value(), expectedMethod)); + props.add(new MqttProperties.BinaryProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_DATA.value(), expectedChallenge)); + + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.AUTH, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttReasonCodeAndPropertiesVariableHeader varHeader = + new MqttReasonCodeAndPropertiesVariableHeader( + MqttAuthenticateReasonCode.CONTINUE_AUTHENTICATION.value(), props); + MqttMessage auth = MqttMessageFactory.newMessage(fixedHeader, varHeader, null); + conn.writeMessage(auth); + } catch (Exception e) { + ctx.fail(e); + } + }); + }); + + AtomicReference received = new AtomicReference<>(); + + server.listen(0).onComplete(ctx.asyncAssertSuccess(s -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.authenticationExchangeHandler(msg -> { + received.set(msg); + ctx.assertEquals(MqttAuthenticateReasonCode.CONTINUE_AUTHENTICATION, msg.reasonCode()); + ctx.assertEquals(expectedMethod, msg.authenticationMethod()); + ctx.assertNotNull(msg.authenticationData()); + ctx.assertTrue(java.util.Arrays.equals(expectedChallenge, msg.authenticationData().getBytes())); + done.complete(); + }); + client.connect(server.actualPort(), "localhost").onComplete(ctx.asyncAssertSuccess()); + })); + + done.awaitSuccess(5000); + ctx.assertNotNull(received.get()); + } + + // ------------------------------------------------------------------------- + + /** + * authenticationExchange() must fail fast when the client is configured for + * a protocol version other than MQTT 5.0 — AUTH is a v5-only packet. + */ + @Test + public void authenticationExchangeRejectedOnNonV5(TestContext ctx) { + Async done = ctx.async(); + + MqttClientOptions v3 = new MqttClientOptions(); // default = MQTT 3.1.1 + MqttClient client = MqttClient.create(vertx, v3); + + server.endpointHandler(ep -> ep.accept(false)); + server.listen(0).onComplete(ctx.asyncAssertSuccess(s -> { + client.connect(server.actualPort(), "localhost").onComplete(ctx.asyncAssertSuccess(connAck -> { + client.authenticationExchange(MqttAuthenticateReasonCode.SUCCESS, MqttProperties.NO_PROPERTIES) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof IllegalStateException); + ctx.assertTrue(err.getMessage().contains("MQTT 5")); + done.complete(); + })); + })); + })); + + done.awaitSuccess(5000); + } + + // ------------------------------------------------------------------------- + + /** + * authenticationExchange() on a v5 client that hasn't connected yet must + * fail with IllegalStateException rather than NPE-ing on a null channel. + */ + @Test + public void authenticationExchangeRejectedWhenNotConnected(TestContext ctx) { + Async done = ctx.async(); + + server.endpointHandler(ep -> ep.accept(false)); + server.listen(0).onComplete(ctx.asyncAssertSuccess(s -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + // Force the client to allocate a context by connecting and immediately disconnecting. + client.connect(server.actualPort(), "localhost").onComplete(ctx.asyncAssertSuccess(connAck -> { + client.disconnect().onComplete(ctx.asyncAssertSuccess(v -> { + client.authenticationExchange(MqttAuthenticateReasonCode.RE_AUTHENTICATE, MqttProperties.NO_PROPERTIES) + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof IllegalStateException); + ctx.assertTrue(err.getMessage().contains("not connected")); + done.complete(); + })); + })); + })); + })); + + done.awaitSuccess(5000); + } + + // ------------------------------------------------------------------------- + + private MqttClientOptions v5Options() { + MqttClientOptions opts = new MqttClientOptions(); + opts.setVersion(MqttVersion.MQTT_5.protocolLevel()); + return opts; + } +} From 466adba57a91e170b7066245853654edcb74ded1 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Fri, 22 May 2026 23:12:23 +0200 Subject: [PATCH 7/9] Add MQTT 5.0 Enhanced Authentication example and tests; allow authentication exchange during connecting state --- .../examples/VertxMqttClientAUTHExamples.java | 167 ++++++++++++++++++ .../io/vertx/mqtt/impl/MqttClientImpl.java | 2 +- .../mqtt/test/client/Mqtt5ClientAuthTest.java | 62 +++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/main/java/examples/VertxMqttClientAUTHExamples.java diff --git a/src/main/java/examples/VertxMqttClientAUTHExamples.java b/src/main/java/examples/VertxMqttClientAUTHExamples.java new file mode 100644 index 00000000..33cec92e --- /dev/null +++ b/src/main/java/examples/VertxMqttClientAUTHExamples.java @@ -0,0 +1,167 @@ +/* + * Copyright 2016 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package examples; + +import static io.netty.handler.codec.mqtt.MqttProperties.MqttPropertyType.AUTHENTICATION_DATA; +import static io.netty.handler.codec.mqtt.MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD; + +import io.netty.handler.codec.mqtt.MqttProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode; + +import javax.crypto.Mac; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * End-to-end demo of MQTT 5.0 Enhanced Authentication (§4.12) using + * SCRAM-SHA-256 (RFC 5802) against an EMQX broker. + * + * Usage: + * java examples.VertxMqttClientAUTHExamples [host] [port] [username] [password] + * Defaults: localhost 1883 user public + */ +public class VertxMqttClientAUTHExamples { + + private static final String AUTH_METHOD = "SCRAM-SHA-256"; + private static final String GS2_HEADER = "n,,"; + + public static void main(String[] args) { + + final String host = args.length > 0 ? args[0] : "localhost"; + final int port = args.length > 1 ? Integer.parseInt(args[1]) : 1883; + final String username = args.length > 2 ? args[2] : "user"; + final String password = args.length > 3 ? args[3] : "public"; + + final String clientNonce = generateNonce(); + final String clientFirstMessageBare = "n=" + saslName(username) + ",r=" + clientNonce; + final byte[] clientFirstMessage = (GS2_HEADER + clientFirstMessageBare).getBytes(StandardCharsets.UTF_8); + + MqttClientOptions option = new MqttClientOptions(); + option.setVersion(5); + option.setAuthenticationMethod(AUTH_METHOD); + option.setAuthenticationData(Buffer.buffer(clientFirstMessage)); + + Vertx vertx = Vertx.vertx(); + MqttClient client = MqttClient.create(vertx, option); + + client.authenticationExchangeHandler(msg -> { + try { + String serverFirstMessage = new String(msg.authenticationData().getBytes(), StandardCharsets.UTF_8); + Map fields = parseScramMessage(serverFirstMessage); + String serverNonce = fields.get("r"); + byte[] salt = Base64.getDecoder().decode(fields.get("s")); + int iterations = Integer.parseInt(fields.get("i")); + + // RFC 5802 §5.1: server MUST extend the client nonce — fail closed otherwise. + if (serverNonce == null || !serverNonce.startsWith(clientNonce)) { + throw new SecurityException("Invalid server nonce"); + } + + String channelBinding = Base64.getEncoder().encodeToString(GS2_HEADER.getBytes(StandardCharsets.UTF_8)); + String clientFinalNoProof = "c=" + channelBinding + ",r=" + serverNonce; + String authMessage = clientFirstMessageBare + "," + serverFirstMessage + "," + clientFinalNoProof; + + byte[] saltedPassword = pbkdf2(password, salt, iterations); + byte[] clientKey = hmacSha256(saltedPassword, "Client Key".getBytes(StandardCharsets.UTF_8)); + byte[] storedKey = MessageDigest.getInstance("SHA-256").digest(clientKey); + byte[] clientSignature = hmacSha256(storedKey, authMessage.getBytes(StandardCharsets.UTF_8)); + byte[] clientProof = xor(clientKey, clientSignature); + + String clientFinalMessage = clientFinalNoProof + + ",p=" + Base64.getEncoder().encodeToString(clientProof); + + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty(AUTHENTICATION_METHOD.value(), AUTH_METHOD)); + props.add(new MqttProperties.BinaryProperty(AUTHENTICATION_DATA.value(), + clientFinalMessage.getBytes(StandardCharsets.UTF_8))); + + client.authenticationExchange(MqttAuthenticateReasonCode.CONTINUE_AUTHENTICATION, props); + } catch (Exception e) { + e.printStackTrace(); + } + }); + + client.connect(port, host).onComplete(ar -> { + if (ar.succeeded()) { + System.out.println("Connected & authenticated as '" + username + "'"); + client.publish("temperature", Buffer.buffer("hello"), MqttQoS.AT_LEAST_ONCE, false, false) + .onComplete(p -> client.disconnect().onComplete(d -> vertx.close())); + } else { + System.err.println("Connect failed: " + ar.cause()); + vertx.close(); + } + }); + } + + private static String generateNonce() { + byte[] bytes = new byte[24]; + new SecureRandom().nextBytes(bytes); + // RFC 5802 §5.1: nonce is "printable" — strip base64 chars disallowed inside SCRAM attrs. + return Base64.getEncoder().encodeToString(bytes).replace("=", "").replace(",", ""); + } + + private static String saslName(String name) { + return name.replace("=", "=3D").replace(",", "=2C"); + } + + private static Map parseScramMessage(String msg) { + Map out = new HashMap<>(); + for (String token : msg.split(",")) { + int eq = token.indexOf('='); + if (eq > 0) { + out.put(token.substring(0, eq), token.substring(eq + 1)); + } + } + return out; + } + + private static byte[] pbkdf2(String password, byte[] salt, int iterations) throws Exception { + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, 256); + try { + return skf.generateSecret(spec).getEncoded(); + } finally { + spec.clearPassword(); + } + } + + private static byte[] hmacSha256(byte[] key, byte[] data) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } + + private static byte[] xor(byte[] a, byte[] b) { + byte[] out = new byte[a.length]; + for (int i = 0; i < a.length; i++) { + out[i] = (byte) (a[i] ^ b[i]); + } + return out; + } +} diff --git a/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java b/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java index 10d1f59f..824b20a6 100644 --- a/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java +++ b/src/main/java/io/vertx/mqtt/impl/MqttClientImpl.java @@ -501,7 +501,7 @@ public Future authenticationExchange(MqttAuthenticateReasonCode reasonCode } synchronized (this) { - if (this.status != Status.CONNECTED) { + if (this.status != Status.CONNECTED && this.status != Status.CONNECTING) { return Future.failedFuture(new IllegalStateException("Client not connected")); } } diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java index eb0ed0f5..985df7b6 100644 --- a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientAuthTest.java @@ -134,6 +134,68 @@ public void clientReceivesAuthFromServer(TestContext ctx) { // ------------------------------------------------------------------------- + /** + * MQTT 5.0 §4.12.1 Enhanced Authentication: the AUTH exchange happens between + * CONNECT and CONNACK, while the client is still in CONNECTING state. The + * client must be able to call authenticationExchange() from inside its + * authenticationExchangeHandler to respond to the server's AUTH challenge — + * the returned Future must succeed (not fail with "Client not connected"). + */ + @Test + public void authenticationExchangeAllowedDuringConnect(TestContext ctx) { + Async done = ctx.async(); + + final String method = "SCRAM-SHA-1"; + final byte[] challenge = "server-challenge".getBytes(); + + server.endpointHandler(ep -> { + // Do NOT accept yet: send AUTH first so the client is still CONNECTING + // when its authenticationExchangeHandler fires. + vertx.runOnContext(v -> { + try { + Field connField = ep.getClass().getDeclaredField("conn"); + connField.setAccessible(true); + NetSocketInternal conn = (NetSocketInternal) connField.get(ep); + + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value(), method)); + props.add(new MqttProperties.BinaryProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_DATA.value(), challenge)); + + MqttFixedHeader fixedHeader = new MqttFixedHeader( + MqttMessageType.AUTH, false, MqttQoS.AT_MOST_ONCE, false, 0); + MqttReasonCodeAndPropertiesVariableHeader varHeader = + new MqttReasonCodeAndPropertiesVariableHeader( + MqttAuthenticateReasonCode.CONTINUE_AUTHENTICATION.value(), props); + MqttMessage auth = MqttMessageFactory.newMessage(fixedHeader, varHeader, null); + conn.writeMessage(auth); + } catch (Exception e) { + ctx.fail(e); + } + }); + }); + + server.listen(0).onComplete(ctx.asyncAssertSuccess(s -> { + MqttClient client = MqttClient.create(vertx, v5Options()); + client.authenticationExchangeHandler(msg -> { + MqttProperties resp = new MqttProperties(); + resp.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value(), method)); + resp.add(new MqttProperties.BinaryProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_DATA.value(), "client-response".getBytes())); + // This call must succeed even though the client is still CONNECTING. + client.authenticationExchange(MqttAuthenticateReasonCode.CONTINUE_AUTHENTICATION, resp) + .onComplete(ctx.asyncAssertSuccess(v -> done.complete())); + }); + client.connect(server.actualPort(), "localhost"); + })); + + done.awaitSuccess(5000); + } + + // ------------------------------------------------------------------------- + /** * authenticationExchange() must fail fast when the client is configured for * a protocol version other than MQTT 5.0 — AUTH is a v5-only packet. From 51dbd9552642624cf4588fbed3293c02d1e17dd1 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Fri, 22 May 2026 23:55:35 +0200 Subject: [PATCH 8/9] Enhance documentation for MQTT 5.0 AUTH packet handling; detail client-side authentication flow and provide example for SCRAM-SHA-256 --- src/main/asciidoc/index.adoc | 141 +++++++++++++++++++++++++++-------- 1 file changed, 111 insertions(+), 30 deletions(-) diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 5caed66e..4034ff2a 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -721,52 +721,133 @@ The client can also actively send a DISCONNECT with reason code and properties v MQTT 5.0 §4.12 introduces an _Enhanced Authentication_ flow built on a new AUTH control packet, designed to support multi-round challenge/response schemes (e.g. SCRAM, Kerberos, mutual proofs) that cannot fit in the single CONNECT -exchange used by 3.1.1. +exchange used by 3.1.1. The client side fully supports this flow, both during the initial connection (§4.12.1) and +for re-authentication on an already established connection (§4.12.2). -==== What is supported today +==== Configuring the CONNECT -The client can advertise an authentication method and provide the first chunk of authentication data inside the CONNECT -packet, which is the initial step of any enhanced authentication exchange: +The first chunk of authentication data, plus the name of the mechanism, are placed on the CONNECT packet via +{@link io.vertx.mqtt.MqttClientOptions}: + +* {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationMethod(java.lang.String)} — UTF-8 name of the mechanism + (MQTT 5.0 §3.1.2.11.9). When set, the broker is expected to drive the rest of the exchange with AUTH packets and + must fail the connection with `Bad authentication method` if it does not support it. +* {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationData(io.vertx.core.buffer.Buffer)} — opaque binary + payload required by the chosen mechanism (MQTT 5.0 §3.1.2.11.10). + +The values the broker returns in the CONNACK can be inspected on the resulting +{@link io.vertx.mqtt.messages.MqttConnAckMessage} via `authenticationMethod()` and `authenticationData()` — for +mechanisms such as SCRAM, the final server signature (`v=...`) is typically delivered there. + +==== Handling AUTH packets from the broker + +Register a handler to be notified when the broker sends an AUTH packet — both during the initial handshake (before +CONNACK, while the client is still in `CONNECTING` state) and during re-authentication on a live connection: [source,java] ---- +client.authenticationExchangeHandler(msg -> { + // msg.reasonCode() — CONTINUE_AUTHENTICATION / SUCCESS / RE_AUTHENTICATE + // msg.authenticationMethod() — echoed by the broker, must match the negotiated method + // msg.authenticationData() — opaque payload for the mechanism (e.g. SCRAM server-first-message) +}); +---- + +See {@link io.vertx.mqtt.MqttClient#authenticationExchangeHandler(io.vertx.core.Handler)} and +{@link io.vertx.mqtt.messages.MqttAuthenticationExchangeMessage}. + +==== Sending AUTH packets + +The client replies to (or initiates) an AUTH exchange through +{@link io.vertx.mqtt.MqttClient#authenticationExchange(io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode, io.netty.handler.codec.mqtt.MqttProperties)}. +It is valid in two phases: + +* *Initial enhanced authentication* (MQTT 5.0 §4.12.1) — call it from inside the + `authenticationExchangeHandler` while `client.connect(...)` is still in flight. Reply with + `CONTINUE_AUTHENTICATION` (0x18) until the broker terminates the exchange with a CONNACK. The returned `Future` + succeeds as soon as the AUTH frame is on the wire; the overall outcome is the one delivered by `client.connect(...)`. +* *Re-authentication* (MQTT 5.0 §4.12.2) — once the connection is established, call it with `RE_AUTHENTICATE` (0x19) + to restart the authentication exchange without tearing down the underlying TCP connection. + +The reason code is supplied via {@link io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode} (`SUCCESS`, +`CONTINUE_AUTHENTICATION`, `RE_AUTHENTICATE`); the `MqttProperties` parameter must include the +`AUTHENTICATION_METHOD` (echoed unchanged for the whole exchange) and, when required by the mechanism, the +`AUTHENTICATION_DATA` payload. + +==== Example: SCRAM-SHA-256 + +The following snippet shows how to drive a full SCRAM-SHA-256 handshake (RFC 5802) against an MQTT 5.0 broker that +advertises `SCRAM-SHA-256` (for example EMQX with the `scram` authenticator). The crypto primitives come from the +JDK — no extra dependency is required. + +Step 1 — generate the client nonce, build `client-first-message` and place it on the CONNECT: + +[source,java] +---- +String clientNonce = randomBase64(24); // securely random, RFC 5802 §5.1 +String clientFirstMessageBare = "n=" + username + ",r=" + clientNonce; +String clientFirstMessage = "n,," + clientFirstMessageBare; // "n,," is the GS2 header + MqttClientOptions options = new MqttClientOptions(); options.setVersion(io.netty.handler.codec.mqtt.MqttVersion.MQTT_5.protocolLevel()); options.setAuthenticationMethod("SCRAM-SHA-256"); -options.setAuthenticationData(io.vertx.core.buffer.Buffer.buffer(clientFirstMessage)); +options.setAuthenticationData(Buffer.buffer(clientFirstMessage.getBytes(StandardCharsets.UTF_8))); MqttClient client = MqttClient.create(vertx, options); ---- -Setters available on {@link io.vertx.mqtt.MqttClientOptions}: - -* {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationMethod(java.lang.String)} — UTF-8 name of the - authentication mechanism (MQTT 5.0 §3.1.2.11.9). When set, the broker is expected to use the AUTH packet - for the subsequent rounds and to fail the connection with `Bad authentication method` if it does not support it. -* {@link io.vertx.mqtt.MqttClientOptions#setAuthenticationData(io.vertx.core.buffer.Buffer)} — opaque binary - payload required by the chosen method (MQTT 5.0 §3.1.2.11.10). +Step 2 — when the broker replies with `server-first-message`, compute the client proof and send +`client-final-message` back through an AUTH packet: -The values returned by the broker in the CONNACK can be inspected on the resulting -{@link io.vertx.mqtt.messages.MqttConnAckMessage} via `authenticationMethod()` and `authenticationData()`. - -==== What is not yet supported +[source,java] +---- +client.authenticationExchangeHandler(msg -> { + // server-first-message: r=,s=,i= + String serverFirst = new String(msg.authenticationData().getBytes(), StandardCharsets.UTF_8); + Map f = parseScram(serverFirst); + String serverNonce = f.get("r"); + byte[] salt = Base64.getDecoder().decode(f.get("s")); + int iterations = Integer.parseInt(f.get("i")); + + // RFC 5802 §5.1: server MUST extend the client nonce — fail closed otherwise. + if (!serverNonce.startsWith(clientNonce)) throw new SecurityException("bad nonce"); + + String clientFinalNoProof = "c=biws,r=" + serverNonce; // biws = base64("n,,") + String authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalNoProof; + + byte[] saltedPwd = pbkdf2(password, salt, iterations); // PBKDF2-HMAC-SHA-256 + byte[] clientKey = hmacSha256(saltedPwd, "Client Key".getBytes()); + byte[] storedKey = sha256(clientKey); + byte[] clientSig = hmacSha256(storedKey, authMessage.getBytes()); + byte[] clientProof = xor(clientKey, clientSig); + + String clientFinal = clientFinalNoProof + + ",p=" + Base64.getEncoder().encodeToString(clientProof); + + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.StringProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_METHOD.value(), "SCRAM-SHA-256")); + props.add(new MqttProperties.BinaryProperty( + MqttProperties.MqttPropertyType.AUTHENTICATION_DATA.value(), + clientFinal.getBytes(StandardCharsets.UTF_8))); + + client.authenticationExchange(MqttAuthenticateReasonCode.CONTINUE_AUTHENTICATION, props); +}); -The full AUTH-exchange API (the additional control-packet round trips that follow the CONNECT) is **not yet ported to -the Vert.x 4 branch**: +client.connect(1883, "broker.example.com").onSuccess(connack -> { + // authenticated; connack.authenticationData() may carry the SCRAM server-final-message (v=...) +}); +---- -* `MqttClient` does not expose a method to send an AUTH packet, nor a handler to be notified when the broker sends one. -* On the server side, {@link io.vertx.mqtt.impl.MqttEndpointImpl} accepts incoming AUTH packets at the wire level - (so a broker can be probed without dropping the TCP connection) but {@code handleAuth} is a stub that does not surface - the AUTH packet to user code, and there is no API to reply with a server-originated AUTH. +A complete, runnable version including the SCRAM helper methods (`pbkdf2`, `hmacSha256`, `sha256`, `xor`, nonce +generation and SASL-name escaping) is available in {@link examples.VertxMqttClientAUTHExamples}. -In practice this means that on this branch only *single-step* enhanced authentication works — i.e. mechanisms where the -data sent on CONNECT is sufficient and the broker can answer with a successful CONNACK without further AUTH round trips. -Multi-step mechanisms and post-connect re-authentication require porting upstream commit `892e923`, which introduces -the public {@code MqttAuthenticationExchangeMessage} API (`MqttClient#authenticationExchangeHandler` / a `sendAuth` -counterpart and the matching `MqttEndpoint` hooks). +==== Server-side AUTH -The transitive types ({@link io.vertx.mqtt.messages.MqttAuthenticationExchangeMessage} and -{@link io.vertx.mqtt.messages.codes.MqttAuthenticateReasonCode}) are already present in this branch so that the -public API surface does not change when that port lands. +On the server side AUTH packets are accepted at the wire level (so a broker can be probed without dropping the TCP +connection) but {@link io.vertx.mqtt.impl.MqttEndpointImpl} does not currently surface them to user code: +{@code handleAuth} is a stub. Implementing an enhanced-authentication endpoint therefore requires extending the +endpoint API; the client-side types are already in place so a future server port will not change this section's API +surface. === Automatic server redirect From 57e2b5a57a2a18407ce6dcce491e6ea737b8a794 Mon Sep 17 00:00:00 2001 From: Domenico Briganti Date: Sat, 27 Jun 2026 16:36:58 +0200 Subject: [PATCH 9/9] Port README/cleanup from client_mqtt5_master (skip server-side AUTH refactor; not on vertx4) Ports the portable subset of the last 4 commits on client_mqtt5_master: - f3820d7: README now states the client is compliant with both 3.1.1 and 5.0 - f6dd00f: drop unused imports (MqttConnAckMessage, java.util.Map, MqttServerOptions) in the touched MQTT 5 test classes 269067a is already satisfied/N-A on vertx4 (no publish(int id,..) family, no example10, @GenIgnore import and 2-arg authenticationExchange doc already present). d96cad9 is skipped: it relies on the server-side MqttEndpoint.authenticationExchange, which vertx4 does not have. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java | 1 - src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java | 1 - .../io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 81781bbc..cf8cb07d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This project provides the following two different components : * **server** : it's able to handle connections, communication and messages exchange with remote [MQTT](http://mqtt.org/) clients. Its API provides a bunch of events related to raw protocol messages received by clients and exposes some functionalities in order to send messages to them. It's not a fully featured MQTT broker but can be used for building something like that or for protocol translation (MQTT <--> ?). -* **client** : it's an [MQTT](http://mqtt.org/) client which is compliant with the 3.1.1 spec. Its API provides a bunch of methods +* **client** : it's an [MQTT](http://mqtt.org/) client which is compliant with both the 3.1.1 and 5.0 specs. Its API provides a bunch of methods for connecting/disconnecting to a broker, publishing messages (with all three different levels of QoS) and subscribing to topics. See the in-source docs for more details: diff --git a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java index 754eafad..b4bab3e5 100644 --- a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java +++ b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientConnectIT.java @@ -21,7 +21,6 @@ import io.vertx.ext.unit.TestContext; import io.vertx.mqtt.MqttClient; import io.vertx.mqtt.MqttClientOptions; -import io.vertx.mqtt.messages.MqttConnAckMessage; import org.junit.After; import org.junit.Test; diff --git a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java index c21eb003..66e2f5e7 100644 --- a/src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java +++ b/src/test/java/io/vertx/mqtt/it/Mqtt5ClientSubscribeIT.java @@ -30,7 +30,6 @@ import org.junit.Test; import java.util.List; -import java.util.Map; /** * Integration tests for MQTT 5.0 SUBSCRIBE against a real Mosquitto 2.x broker. diff --git a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java index 1aa84eec..c8702d08 100644 --- a/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java +++ b/src/test/java/io/vertx/mqtt/test/client/Mqtt5ClientTopicAliasTest.java @@ -26,7 +26,6 @@ import io.vertx.mqtt.MqttClient; import io.vertx.mqtt.MqttClientOptions; import io.vertx.mqtt.MqttServer; -import io.vertx.mqtt.MqttServerOptions; import org.junit.After; import org.junit.Before; import org.junit.Test;