diff --git a/common/util/src/main/java/org/thingsboard/common/util/SslUtil.java b/common/util/src/main/java/org/thingsboard/common/util/SslUtil.java
index ecf860a9b1..467a769c4e 100644
--- a/common/util/src/main/java/org/thingsboard/common/util/SslUtil.java
+++ b/common/util/src/main/java/org/thingsboard/common/util/SslUtil.java
@@ -33,10 +33,13 @@ import org.bouncycastle.pkcs.PKCSException;
import org.bouncycastle.pkcs.jcajce.JcePKCSPBEInputDecryptorProviderBuilder;
import org.thingsboard.server.common.data.StringUtils;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
+import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.CertificateException;
@@ -134,4 +137,11 @@ public class SslUtil {
return StringUtils.isEmpty(passStr) ? EMPTY_PASS : passStr.toCharArray();
}
+ @SneakyThrows
+ public static X509Certificate[] getDefaultTrustedCertificates() {
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init((KeyStore) null); // JVM default trust store (cacerts)
+ return ((X509TrustManager) tmf.getTrustManagers()[0]).getAcceptedIssuers();
+ }
+
}
diff --git a/monitoring/pom.xml b/monitoring/pom.xml
index c3dfaf00c7..2241424a7b 100644
--- a/monitoring/pom.xml
+++ b/monitoring/pom.xml
@@ -79,6 +79,10 @@
org.apache.httpcomponents
httpclient
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
com.slack.api
slack-api-client
diff --git a/monitoring/src/main/conf/logback.xml b/monitoring/src/main/conf/logback.xml
index 50536a7110..de8b2c3cd5 100644
--- a/monitoring/src/main/conf/logback.xml
+++ b/monitoring/src/main/conf/logback.xml
@@ -29,6 +29,7 @@
+
diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java b/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java
index 9218b78bea..3eec18bf13 100644
--- a/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java
+++ b/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java
@@ -49,6 +49,8 @@ public class ThingsboardMonitoringApplication {
@Value("${monitoring.monitoring_rate_ms}")
private int monitoringRateMs;
+ @Value("${monitoring.session_duration_ms}")
+ private long sessionDurationMs;
ScheduledExecutorService scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("monitoring");
@@ -66,7 +68,7 @@ public class ThingsboardMonitoringApplication {
for (int i = 0; i < monitoringServices.size(); i++) {
int initialDelay = (monitoringRateMs / monitoringServices.size()) * i;
BaseMonitoringService, ?> service = monitoringServices.get(i);
- log.info("Scheduling initialDelay {}, fixedDelay {} for monitoring '{}' ", initialDelay, monitoringRateMs, service.getClass().getSimpleName());
+ log.info("Scheduling initialDelay {}, fixedDelay {} for monitoring '{}', session duration: {}ms", initialDelay, monitoringRateMs, service.getClass().getSimpleName(), sessionDurationMs);
scheduler.scheduleWithFixedDelay(service::runChecks, initialDelay, monitoringRateMs, TimeUnit.MILLISECONDS);
}
diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java
index 78aca6e51b..72b608b1cd 100644
--- a/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java
+++ b/monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java
@@ -19,8 +19,6 @@ import lombok.Getter;
import lombok.Setter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
-import org.eclipse.californium.core.config.CoapConfig;
import org.eclipse.californium.elements.config.Configuration;
import org.eclipse.californium.scandium.config.DtlsConfig;
import org.eclipse.leshan.client.LeshanClient;
@@ -95,11 +93,6 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable {
}
Security security = noSec(serverUri, 123);
- Configuration coapConfig = new Configuration();
- String portStr = StringUtils.substringAfterLast(serverUri, ":");
- if (StringUtils.isNotEmpty(portStr)) {
- coapConfig.set(CoapConfig.COAP_PORT, Integer.parseInt(portStr));
- }
LwM2mModel model = new StaticModel(models);
ObjectsInitializer initializer = new ObjectsInitializer(model);
diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportType.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportType.java
index d78e54d308..53a4d55784 100644
--- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportType.java
+++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportType.java
@@ -28,7 +28,7 @@ import org.thingsboard.monitoring.service.transport.impl.MqttTransportHealthChec
public enum TransportType {
MQTT("MQTT", MqttTransportHealthChecker.class),
- COAP("CoAP",CoapTransportHealthChecker.class),
+ COAP("CoAP", CoapTransportHealthChecker.class),
HTTP("HTTP", HttpTransportHealthChecker.class),
LWM2M("LwM2M", Lwm2mTransportHealthChecker.class);
diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java
index c0e85da1ac..9f30efdad5 100644
--- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java
+++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java
@@ -82,6 +82,7 @@ public abstract class BaseHealthChecker extends BaseHealthChecker {
@Value("${monitoring.calculated_fields.enabled:true}")
private boolean calculatedFieldsMonitoringEnabled;
+ @Value("${monitoring.session_duration_ms:3600000}")
+ private long sessionDurationMs;
+
+ private long sessionStartTimeNanos;
+
+ protected boolean isSessionExpired() {
+ return sessionDurationMs > 0 && System.nanoTime() - sessionStartTimeNanos >= TimeUnit.MILLISECONDS.toNanos(sessionDurationMs);
+ }
+
+ protected void recordSessionStart() {
+ sessionStartTimeNanos = System.nanoTime();
+ }
+
+ protected final void reconnectIfNeeded(boolean clientExists, ThrowingAction disconnect, ThrowingAction connect) throws Exception {
+ if (clientExists) {
+ log.info("Reconnecting {} client to {}", getTransportType(), target.getBaseUrl());
+ disconnect.run();
+ }
+ connect.run();
+ recordSessionStart();
+ log.debug("Connected {} client to {}", getTransportType(), target.getBaseUrl());
+ }
+
+ @FunctionalInterface
+ protected interface ThrowingAction {
+ void run() throws Exception;
+ }
public TransportHealthChecker(C config, TransportMonitoringTarget target) {
super(config, target);
diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java
index 953b5c2415..42862a04f4 100644
--- a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java
+++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java
@@ -20,7 +20,10 @@ import org.eclipse.californium.core.CoapClient;
import org.eclipse.californium.core.CoapResponse;
import org.eclipse.californium.core.coap.CoAP;
import org.eclipse.californium.core.coap.MediaTypeRegistry;
+import org.eclipse.californium.core.config.CoapConfig;
+import org.eclipse.californium.core.network.CoapEndpoint;
import org.eclipse.californium.elements.config.SystemConfig;
+import org.eclipse.californium.scandium.config.DtlsConfig;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@@ -28,6 +31,7 @@ import org.thingsboard.monitoring.config.transport.CoapTransportMonitoringConfig
import org.thingsboard.monitoring.config.transport.TransportMonitoringTarget;
import org.thingsboard.monitoring.config.transport.TransportType;
import org.thingsboard.monitoring.service.transport.TransportHealthChecker;
+import org.thingsboard.monitoring.util.DtlsClientConnectorFactory;
import java.io.IOException;
@@ -38,9 +42,12 @@ public class CoapTransportHealthChecker extends TransportHealthChecker {
String accessToken = target.getDevice().getCredentials().getCredentialsId();
String uri = target.getBaseUrl() + "/api/v1/" + accessToken + "/telemetry";
coapClient = new CoapClient(uri);
+ if (isSecure()) {
+ coapEndpoint = new CoapEndpoint.Builder().setConnector(DtlsClientConnectorFactory.jvmTrustedDtlsClientConnector()).build();
+ coapClient.setEndpoint(coapEndpoint);
+ }
coapClient.setTimeout((long) config.getRequestTimeoutMs());
- log.debug("Initialized CoAP client for URI {}", uri);
- }
+ });
}
@Override
protected void sendTestPayload(String payload) throws Exception {
CoapResponse response = coapClient.post(payload, MediaTypeRegistry.APPLICATION_JSON);
- CoAP.ResponseCode code = response.getCode();
- if (code.codeClass != CoAP.CodeClass.SUCCESS_RESPONSE.value) {
- throw new IOException("COAP client didn't receive success response from transport");
+ if (response == null) {
+ throw new IOException(getTransportType() + " request timed out");
+ }
+ if (response.getCode().codeClass != CoAP.CodeClass.SUCCESS_RESPONSE.value) {
+ throw new IOException(getTransportType() + " client didn't receive success response from transport");
}
}
@Override
- protected void destroyClient() throws Exception {
+ protected void destroyClient() {
if (coapClient != null) {
+ shutdownCoapClient();
+ }
+ }
+
+ private void shutdownCoapClient() {
+ try {
coapClient.shutdown();
+ } catch (Exception e) {
+ log.warn("Failed to shutdown CoAP client", e);
+ } finally {
+ if (coapEndpoint != null) {
+ try {
+ coapEndpoint.destroy();
+ } catch (Exception e) {
+ log.warn("Failed to destroy CoAP endpoint", e);
+ }
+ coapEndpoint = null;
+ }
coapClient = null;
- log.info("Disconnected CoAP client");
+ log.debug("Disconnected {} client", getTransportType());
}
}
@@ -80,4 +112,8 @@ public class CoapTransportHealthChecker extends TransportHealthChecker {
String clientId = MqttAsyncClient.generateClientId();
String accessToken = target.getDevice().getCredentials().getCredentialsId();
mqttClient = new MqttClient(target.getBaseUrl(), clientId, new MemoryPersistence());
@@ -58,8 +61,7 @@ public class MqttTransportHealthChecker extends TransportHealthChecker CLIENT_SIG_ALGS = List.of(
+ SignatureAndHashAlgorithm.SHA256_WITH_ECDSA,
+ SignatureAndHashAlgorithm.SHA384_WITH_ECDSA,
+ SignatureAndHashAlgorithm.valueOf("SHA512withECDSA"),
+ SignatureAndHashAlgorithm.SHA256_WITH_RSA,
+ SignatureAndHashAlgorithm.valueOf("SHA384withRSA"),
+ SignatureAndHashAlgorithm.valueOf("SHA512withRSA"),
+ SignatureAndHashAlgorithm.INTRINSIC_WITH_ED25519);
+
+ // default implicitly limits a cert-verifier-only client to ECDSA suites, breaking RSA-cert servers
+ private static final List CLIENT_CIPHER_SUITES = List.of(
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8,
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8,
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM,
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CCM);
+
+ private static final X509Certificate[] TRUSTED_CERTIFICATES = SslUtil.getDefaultTrustedCertificates();
+
+ static {
+ SystemConfig.register();
+ CoapConfig.register();
+ DtlsConfig.register();
+ }
+
+ private DtlsClientConnectorFactory() {
+ }
+
+ public static DTLSConnector jvmTrustedDtlsClientConnector() {
+ Configuration config = Configuration.getStandard();
+ config.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); // no client cert required
+ config.set(DtlsConfig.DTLS_USE_SERVER_NAME_INDICATION, true); // select the correct server cert
+ config.set(DtlsConfig.DTLS_SIGNATURE_AND_HASH_ALGORITHMS, CLIENT_SIG_ALGS);
+ config.set(DtlsConfig.DTLS_CIPHER_SUITES, CLIENT_CIPHER_SUITES);
+ // hostname is validated by WildcardAwareCertificateVerifier, since Scandium's own
+ // DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT check doesn't support wildcard SANs
+ return new DTLSConnector(DtlsConnectorConfig.builder(config)
+ .setAdvancedCertificateVerifier(new WildcardAwareCertificateVerifier(TRUSTED_CERTIFICATES))
+ .build());
+ }
+
+}
diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifier.java b/monitoring/src/main/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifier.java
new file mode 100644
index 0000000000..f617502848
--- /dev/null
+++ b/monitoring/src/main/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifier.java
@@ -0,0 +1,178 @@
+/**
+ * Copyright © 2016-2026 The Thingsboard Authors
+ *
+ * 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 org.thingsboard.monitoring.util;
+
+import org.apache.hc.client5.http.psl.PublicSuffixMatcher;
+import org.apache.hc.client5.http.psl.PublicSuffixMatcherLoader;
+import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
+import org.eclipse.californium.elements.util.CertPathUtil;
+import org.eclipse.californium.elements.util.StringUtil;
+import org.eclipse.californium.scandium.dtls.AlertMessage;
+import org.eclipse.californium.scandium.dtls.CertificateMessage;
+import org.eclipse.californium.scandium.dtls.CertificateType;
+import org.eclipse.californium.scandium.dtls.CertificateVerificationResult;
+import org.eclipse.californium.scandium.dtls.ConnectionId;
+import org.eclipse.californium.scandium.dtls.HandshakeException;
+import org.eclipse.californium.scandium.dtls.HandshakeResultHandler;
+import org.eclipse.californium.scandium.dtls.x509.NewAdvancedCertificateVerifier;
+import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier;
+import org.eclipse.californium.scandium.util.ServerName;
+import org.eclipse.californium.scandium.util.ServerNames;
+
+import javax.net.ssl.SSLException;
+import javax.security.auth.x500.X500Principal;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Scandium's own subject check ({@code DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT}) rejects wildcard
+ * certificates outright, so this verifier delegates chain-of-trust to
+ * {@link StaticNewAdvancedCertificateVerifier} and hostname/wildcard matching to Apache HttpClient's
+ * {@link DefaultHostnameVerifier}, constructed with a real {@link PublicSuffixMatcher} (the no-arg
+ * constructor silently disables its public-suffix protection). {@link #matchesOverlyBroadWildcard}
+ * covers the one gap that verifier leaves open: multi-label public suffixes like {@code *.co.uk}.
+ */
+class WildcardAwareCertificateVerifier implements NewAdvancedCertificateVerifier {
+
+ /** SAN {@code GeneralName} type for {@code dNSName}, see RFC 5280 4.2.1.6. */
+ private static final int SAN_TYPE_DNS_NAME = 2;
+
+ private static final PublicSuffixMatcher PUBLIC_SUFFIX_MATCHER = PublicSuffixMatcherLoader.getDefault();
+ private static final DefaultHostnameVerifier HOSTNAME_VERIFIER = new DefaultHostnameVerifier(PUBLIC_SUFFIX_MATCHER);
+
+ private final NewAdvancedCertificateVerifier delegate;
+
+ WildcardAwareCertificateVerifier(X509Certificate[] trustedCertificates) {
+ this(StaticNewAdvancedCertificateVerifier.builder()
+ .setTrustedCertificates(trustedCertificates)
+ .build());
+ }
+
+ WildcardAwareCertificateVerifier(NewAdvancedCertificateVerifier delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public List getSupportedCertificateTypes() {
+ return delegate.getSupportedCertificateTypes();
+ }
+
+ @Override
+ public CertificateVerificationResult verifyCertificate(ConnectionId cid, ServerNames serverNames, InetSocketAddress remotePeer,
+ boolean clientUsage, boolean verifySubject, boolean truncateCertificatePath, CertificateMessage message) {
+ CertificateVerificationResult result = delegate.verifyCertificate(cid, serverNames, remotePeer,
+ clientUsage, false, truncateCertificatePath, message);
+ if (!verifySubject || result.getException() != null
+ || result.getCertificatePath() == null || result.getCertificatePath().getCertificates().isEmpty()) {
+ return result;
+ }
+
+ X509Certificate leafCertificate = (X509Certificate) result.getCertificatePath().getCertificates().get(0);
+ HandshakeException mismatch = verifySubject(serverNames, remotePeer, leafCertificate);
+ return mismatch == null ? result : new CertificateVerificationResult(cid, mismatch, null);
+ }
+
+ @Override
+ public List getAcceptedIssuers() {
+ return delegate.getAcceptedIssuers();
+ }
+
+ @Override
+ public void setResultHandler(HandshakeResultHandler resultHandler) {
+ delegate.setResultHandler(resultHandler);
+ }
+
+ static HandshakeException verifySubject(ServerNames serverNames, InetSocketAddress remotePeer, X509Certificate certificate) {
+ String destination = getDestination(serverNames, remotePeer);
+ if (destination == null) {
+ return null;
+ }
+ if (matchesOverlyBroadWildcard(certificate, destination)) {
+ return mismatchException(certificate, "wildcard SAN covering destination '" + destination + "' spans a public suffix");
+ }
+ try {
+ HOSTNAME_VERIFIER.verify(destination, certificate);
+ return null;
+ } catch (SSLException e) {
+ return mismatchException(certificate, e.getMessage());
+ }
+ }
+
+ // Only the SAN entry that would actually match destination is checked, so an unrelated
+ // overly-broad wildcard elsewhere on a multi-domain certificate can't cause a false rejection.
+ private static boolean matchesOverlyBroadWildcard(X509Certificate certificate, String destination) {
+ int firstDot = destination.indexOf('.');
+ if (firstDot < 0) {
+ return false;
+ }
+ String destinationSuffix = destination.substring(firstDot + 1);
+ try {
+ Collection> subjectAlternativeNames = certificate.getSubjectAlternativeNames();
+ if (subjectAlternativeNames == null) {
+ return false;
+ }
+ for (List> entry : subjectAlternativeNames) {
+ if (isDnsNameEntry(entry)) {
+ String dnsName = (String) entry.get(1);
+ if (dnsName != null && dnsName.startsWith("*.")
+ && dnsName.substring(2).equalsIgnoreCase(destinationSuffix)
+ && PUBLIC_SUFFIX_MATCHER.matches(destinationSuffix)) {
+ return true;
+ }
+ }
+ }
+ } catch (CertificateParsingException e) {
+ return true; // fail closed: can't safely evaluate the SAN list
+ }
+ return false;
+ }
+
+ private static boolean isDnsNameEntry(List> subjectAlternativeNameEntry) {
+ return subjectAlternativeNameEntry.size() >= 2 && Integer.valueOf(SAN_TYPE_DNS_NAME).equals(subjectAlternativeNameEntry.get(0));
+ }
+
+ private static HandshakeException mismatchException(X509Certificate certificate, String reason) {
+ AlertMessage alert = new AlertMessage(AlertMessage.AlertLevel.FATAL, AlertMessage.AlertDescription.BAD_CERTIFICATE);
+ return new HandshakeException("Certificate " + CertPathUtil.getSubjectsCn(certificate) + ": " + reason, alert);
+ }
+
+ private static String getDestination(ServerNames serverNames, InetSocketAddress remotePeer) {
+ String hostName = remotePeer != null ? StringUtil.toHostString(remotePeer) : null;
+ String literalIp = null;
+ if (remotePeer != null) {
+ InetAddress address = remotePeer.getAddress();
+ if (address != null) {
+ literalIp = address.getHostAddress();
+ }
+ }
+ if (serverNames != null) {
+ ServerName serverName = serverNames.getServerName(ServerName.NameType.HOST_NAME);
+ if (serverName != null) {
+ hostName = serverName.getNameAsString();
+ }
+ }
+ if (hostName != null && hostName.equals(literalIp)) {
+ // no SNI hostname was presented, only a literal IP address to check
+ hostName = null;
+ }
+ return hostName != null ? hostName : literalIp;
+ }
+
+}
diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml
index 768fd2e4c7..8454d38862 100644
--- a/monitoring/src/main/resources/tb-monitoring.yml
+++ b/monitoring/src/main/resources/tb-monitoring.yml
@@ -36,6 +36,11 @@ monitoring:
monitoring_rate_ms: '${MONITORING_RATE_MS:10000}'
# Maximum time between request to transport and WebSocket update
check_timeout_ms: '${CHECK_TIMEOUT_MS:5000}'
+ # Transport session duration in milliseconds. Connection is reused until it expires, then reconnected.
+ # Set to 0 to keep the session open indefinitely (reconnect only on failure).
+ # Only honored by the CoAP and MQTT transports — HTTP doesn't keep a persistent connection to expire,
+ # and LwM2M manages its own client lifecycle independently of this setting.
+ session_duration_ms: '${SESSION_DURATION_MS:3600000}'
# Failures threshold for notifying
failures_threshold: '${FAILURES_THRESHOLD:1}'
@@ -51,8 +56,9 @@ monitoring:
# MQTT QoS
qos: '${MQTT_QOS_LEVEL:1}'
targets:
- # MQTT transport base url, tcp://DOMAIN:1883 by default
- - base_url: '${MQTT_TRANSPORT_BASE_URL:tcp://${monitoring.domain}:1883}'
+ # MQTT transport base url. Port is optional — if omitted, defaults by scheme (tcp: 1883, ssl: 8883).
+ # To enable TLS, change scheme to ssl://, e.g. ssl://DOMAIN or ssl://DOMAIN:8883
+ - base_url: '${MQTT_TRANSPORT_BASE_URL:tcp://${monitoring.domain}}'
# Queue to use for target device
queue: '${MQTT_TRANSPORT_USED_QUEUE:Main}'
# Whether to monitor IPs associated with the domain from base url
@@ -68,7 +74,8 @@ monitoring:
# CoAP request timeout in milliseconds
request_timeout_ms: '${COAP_REQUEST_TIMEOUT_MS:4000}'
targets:
- # CoAP transport base url, coap://DOMAIN by default
+ # CoAP transport base url. Port is optional — if omitted, defaults by scheme (coap: 5683, coaps: 5684).
+ # To enable DTLS, change scheme to coaps://, e.g. coaps://DOMAIN or coaps://DOMAIN:5684
- base_url: '${COAP_TRANSPORT_BASE_URL:coap://${monitoring.domain}}'
# Queue to use for target device
queue: '${COAP_TRANSPORT_USED_QUEUE:Main}'
@@ -85,7 +92,8 @@ monitoring:
# HTTP request timeout in milliseconds
request_timeout_ms: '${HTTP_REQUEST_TIMEOUT_MS:4000}'
targets:
- # HTTP transport base url, http://DOMAIN by default
+ # HTTP transport base url. Port is optional — if omitted, defaults by scheme (http: 80, https: 443).
+ # To enable TLS, change scheme to https://, e.g. https://DOMAIN or https://DOMAIN:443
- base_url: '${HTTP_TRANSPORT_BASE_URL:http://${monitoring.domain}}'
# Queue to use for target device
queue: '${HTTP_TRANSPORT_USED_QUEUE:Main}'
@@ -102,7 +110,8 @@ monitoring:
# LwM2M request timeout in milliseconds
request_timeout_ms: '${LWM2M_REQUEST_TIMEOUT_MS:4000}'
targets:
- # LwM2M transport base url, coap://DOMAIN:5685 by default
+ # LwM2M transport base url. Port must be specified explicitly (not auto-defaulted).
+ # DTLS (coaps://) is not supported — the client always registers without security.
- base_url: '${LWM2M_TRANSPORT_BASE_URL:coap://${monitoring.domain}:5685}'
# Queue to use for target device
queue: '${LWM2M_TRANSPORT_USED_QUEUE:Main}'
diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/service/transport/TransportHealthCheckerTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/service/transport/TransportHealthCheckerTest.java
new file mode 100644
index 0000000000..bff80fd808
--- /dev/null
+++ b/monitoring/src/test/java/org/thingsboard/monitoring/service/transport/TransportHealthCheckerTest.java
@@ -0,0 +1,68 @@
+/**
+ * Copyright © 2016-2026 The Thingsboard Authors
+ *
+ * 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 org.thingsboard.monitoring.service.transport;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+
+class TransportHealthCheckerTest {
+
+ @Test
+ void isSessionExpired_durationDisabled_returnsFalseRegardlessOfElapsedTime() {
+ TransportHealthChecker> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS);
+ ReflectionTestUtils.setField(checker, "sessionDurationMs", 0L);
+ ReflectionTestUtils.setField(checker, "sessionStartTimeNanos", System.nanoTime() - TimeUnit.DAYS.toNanos(1));
+
+ assertThat(checker.isSessionExpired()).isFalse();
+ }
+
+ @Test
+ void isSessionExpired_beforeDurationElapsed_returnsFalse() {
+ TransportHealthChecker> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS);
+ ReflectionTestUtils.setField(checker, "sessionDurationMs", TimeUnit.MINUTES.toMillis(5));
+ checker.recordSessionStart();
+
+ assertThat(checker.isSessionExpired()).isFalse();
+ }
+
+ @Test
+ void isSessionExpired_afterDurationElapsed_returnsTrue() {
+ TransportHealthChecker> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS);
+ ReflectionTestUtils.setField(checker, "sessionDurationMs", TimeUnit.SECONDS.toMillis(1));
+ ReflectionTestUtils.setField(checker, "sessionStartTimeNanos", System.nanoTime() - TimeUnit.SECONDS.toNanos(10));
+
+ assertThat(checker.isSessionExpired()).isTrue();
+ }
+
+ @Test
+ void recordSessionStart_recordsCurrentNanoTime() {
+ TransportHealthChecker> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS);
+
+ long before = System.nanoTime();
+ checker.recordSessionStart();
+ long after = System.nanoTime();
+
+ long recorded = (long) ReflectionTestUtils.getField(checker, "sessionStartTimeNanos");
+ assertThat(recorded).isBetween(before, after);
+ }
+
+}
\ No newline at end of file
diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifierTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifierTest.java
new file mode 100644
index 0000000000..a002119057
--- /dev/null
+++ b/monitoring/src/test/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifierTest.java
@@ -0,0 +1,262 @@
+/**
+ * Copyright © 2016-2026 The Thingsboard Authors
+ *
+ * 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 org.thingsboard.monitoring.util;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.eclipse.californium.scandium.dtls.AlertMessage;
+import org.eclipse.californium.scandium.dtls.CertificateMessage;
+import org.eclipse.californium.scandium.dtls.CertificateVerificationResult;
+import org.eclipse.californium.scandium.dtls.ConnectionId;
+import org.eclipse.californium.scandium.dtls.HandshakeException;
+import org.eclipse.californium.scandium.dtls.x509.NewAdvancedCertificateVerifier;
+import org.eclipse.californium.scandium.util.ServerNames;
+import org.junit.jupiter.api.Test;
+
+import javax.security.auth.x500.X500Principal;
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.Security;
+import java.security.cert.CertPath;
+import java.security.cert.CertificateFactory;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class WildcardAwareCertificateVerifierTest {
+
+ static {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ @Test
+ void verifySubject_sniHostnameMatchesExactCert_returnsNoMismatch() throws Exception {
+ X509Certificate cert = selfSignedCertWithDnsSan("coap.example.com");
+ ServerNames serverNames = ServerNames.newInstance("coap.example.com");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert);
+
+ assertThat(mismatch).isNull();
+ }
+
+ @Test
+ void verifySubject_sniHostnameMatchesWildcardCert_returnsNoMismatch() throws Exception {
+ X509Certificate cert = selfSignedCertWithDnsSan("*.example.com");
+ ServerNames serverNames = ServerNames.newInstance("coap.example.com");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert);
+
+ assertThat(mismatch).isNull();
+ }
+
+ @Test
+ void verifySubject_sniHostnameMismatch_returnsException() throws Exception {
+ X509Certificate cert = selfSignedCertWithDnsSan("other.example.com");
+ ServerNames serverNames = ServerNames.newInstance("coap.example.com");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert);
+
+ assertThat(mismatch).isNotNull();
+ }
+
+ @Test
+ void verifySubject_overlyBroadWildcard_returnsException() throws Exception {
+ // A cert for "*.co.uk" must never be accepted for an arbitrary "foo.co.uk" host.
+ X509Certificate cert = selfSignedCertWithDnsSan("*.co.uk");
+ ServerNames serverNames = ServerNames.newInstance("foo.co.uk");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert);
+
+ assertThat(mismatch).isNotNull();
+ }
+
+ @Test
+ void verifySubject_overlyBroadWildcardOnUnrelatedSan_doesNotAffectMatchingSan_returnsNoMismatch() throws Exception {
+ // Regression test: a multi-SAN cert (e.g. shared/CDN hosting) carrying an unrelated
+ // overly-broad wildcard for a different domain must not cause a false rejection of a
+ // connection that legitimately matches a different, narrow SAN entry on the same cert.
+ X509Certificate cert = selfSignedCert(
+ new GeneralName(GeneralName.dNSName, "coap.example.com"),
+ new GeneralName(GeneralName.dNSName, "*.co.uk"));
+ ServerNames serverNames = ServerNames.newInstance("coap.example.com");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert);
+
+ assertThat(mismatch).isNull();
+ }
+
+ @Test
+ void verifySubject_ipLiteralTargetWithMatchingIpSan_returnsNoMismatch() throws Exception {
+ X509Certificate cert = selfSignedCertWithIpSan("192.0.2.10");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(null, remotePeer, cert);
+
+ assertThat(mismatch).isNull();
+ }
+
+ @Test
+ void verifySubject_ipLiteralTargetAgainstDnsOnlyCert_returnsException() throws Exception {
+ // Regression test: an IP-literal coaps:// target (e.g. monitoring's check_domain_ips
+ // feature) has no SNI hostname, so it must be matched against IP SANs, not DNS SANs.
+ X509Certificate cert = selfSignedCertWithDnsSan("coap.example.com");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(null, remotePeer, cert);
+
+ assertThat(mismatch).isNotNull();
+ }
+
+ @Test
+ void verifyCertificate_delegateReportsMismatch_returnsDelegateExceptionWithoutSubjectCheck() throws Exception {
+ NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class);
+ AlertMessage alert = new AlertMessage(AlertMessage.AlertLevel.FATAL, AlertMessage.AlertDescription.BAD_CERTIFICATE);
+ CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY,
+ new HandshakeException("untrusted", alert), null);
+ when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult);
+ WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate);
+
+ CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"),
+ new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class));
+
+ assertThat(result).isSameAs(delegateResult);
+ }
+
+ @Test
+ void verifyCertificate_verifySubjectFalse_skipsHostnameCheckEvenOnMismatch() throws Exception {
+ X509Certificate cert = selfSignedCertWithDnsSan("other.example.com");
+ NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class);
+ CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, certPath(cert), null);
+ when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult);
+ WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate);
+
+ CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"),
+ new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, false, false, mock(CertificateMessage.class));
+
+ assertThat(result).isSameAs(delegateResult);
+ }
+
+ @Test
+ void verifyCertificate_emptyCertPath_returnsDelegateResultUnchanged() throws Exception {
+ NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class);
+ CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY,
+ CertificateFactory.getInstance("X.509").generateCertPath(List.of()), null);
+ when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult);
+ WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate);
+
+ CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"),
+ new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class));
+
+ assertThat(result).isSameAs(delegateResult);
+ }
+
+ @Test
+ void verifyCertificate_matchingHostname_returnsDelegateResultUnchanged() throws Exception {
+ X509Certificate cert = selfSignedCertWithDnsSan("coap.example.com");
+ NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class);
+ CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, certPath(cert), null);
+ when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult);
+ WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate);
+
+ CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"),
+ new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class));
+
+ assertThat(result).isSameAs(delegateResult);
+ }
+
+ @Test
+ void verifyCertificate_mismatchingHostname_returnsNewExceptionResult() throws Exception {
+ X509Certificate cert = selfSignedCertWithDnsSan("other.example.com");
+ NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class);
+ CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, certPath(cert), null);
+ when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult);
+ WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate);
+
+ CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"),
+ new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class));
+
+ assertThat(result).isNotSameAs(delegateResult);
+ assertThat(result.getException()).isNotNull();
+ }
+
+ @Test
+ void verifySubject_certificateParsingExceptionOnSanLookup_failsClosed() throws Exception {
+ X509Certificate cert = mock(X509Certificate.class);
+ when(cert.getSubjectAlternativeNames()).thenThrow(new CertificateParsingException("malformed SAN"));
+ when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test"));
+ ServerNames serverNames = ServerNames.newInstance("coap.example.com");
+ InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684);
+
+ HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert);
+
+ assertThat(mismatch).isNotNull();
+ }
+
+ private static CertPath certPath(X509Certificate cert) throws Exception {
+ return CertificateFactory.getInstance("X.509").generateCertPath(List.of(cert));
+ }
+
+ private static X509Certificate selfSignedCertWithDnsSan(String dnsName) throws Exception {
+ return selfSignedCert(new GeneralName(GeneralName.dNSName, dnsName));
+ }
+
+ private static X509Certificate selfSignedCertWithIpSan(String ipAddress) throws Exception {
+ return selfSignedCert(new GeneralName(GeneralName.iPAddress, ipAddress));
+ }
+
+ private static X509Certificate selfSignedCert(GeneralName... sanEntries) throws Exception {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
+ keyPairGenerator.initialize(256);
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ X500Name subject = new X500Name("CN=test");
+ Date notBefore = Date.from(Instant.now().minusSeconds(3600));
+ Date notAfter = Date.from(Instant.now().plusSeconds(3600));
+ JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
+ subject, BigInteger.ONE, notBefore, notAfter, subject, keyPair.getPublic());
+ builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(sanEntries));
+
+ ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keyPair.getPrivate());
+ return new JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(builder.build(signer));
+ }
+
+}