From 5903f52acac6a1c54a4a9b51aba0532df8bcf85a Mon Sep 17 00:00:00 2001 From: pon0marev Date: Thu, 25 Jun 2026 17:27:23 +0300 Subject: [PATCH 01/13] feat(monitoring): add CoAPS transport support and improve connection lifecycle - Add CoapsTransportMonitoringConfig and TlsConfig for secure transport configuration - Implement CoAPS health checker using Scandium DTLS with AnonymousCertificateProvider - Add MQTT transport SSL support via ssl:// endpoint (MQTTS as separate type removed) - Reconnect on every probe in all transports for accurate certificate change detection - Add CoAPS and CoAP latency series to the monitoring dashboard - Fix cert chain download to parse full PEM bundles and add HTTP timeout - Add truststore fallback when fetchCertificateChain fails --- monitoring/src/main/conf/logback.xml | 2 + .../CoapTransportMonitoringConfig.java | 6 + .../CoapsTransportMonitoringConfig.java | 36 +++++ .../config/transport/TlsConfig.java | 42 ++++++ .../config/transport/TransportType.java | 5 +- .../monitoring/service/BaseHealthChecker.java | 2 + .../impl/CoapTransportHealthChecker.java | 138 ++++++++++++++++-- .../impl/MqttTransportHealthChecker.java | 42 +++--- .../thingsboard/monitoring/util/SslUtil.java | 65 +++++++++ .../resources/dashboard_cloud_monitoring.json | 28 ++++ .../src/main/resources/tb-monitoring.yml | 26 +++- 11 files changed, 362 insertions(+), 30 deletions(-) create mode 100644 monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java create mode 100644 monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java create mode 100644 monitoring/src/main/java/org/thingsboard/monitoring/util/SslUtil.java diff --git a/monitoring/src/main/conf/logback.xml b/monitoring/src/main/conf/logback.xml index 50536a7110..269f57b91c 100644 --- a/monitoring/src/main/conf/logback.xml +++ b/monitoring/src/main/conf/logback.xml @@ -29,6 +29,8 @@ + + diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java index 761ed227c2..000087e836 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.monitoring.config.transport; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -22,8 +24,12 @@ import org.springframework.stereotype.Component; @Component @ConditionalOnProperty(name = "monitoring.transports.coap.enabled", havingValue = "true") @ConfigurationProperties(prefix = "monitoring.transports.coap") +@Data +@EqualsAndHashCode(callSuper = true) public class CoapTransportMonitoringConfig extends TransportMonitoringConfig { + private TlsConfig tls; + @Override public TransportType getTransportType() { return TransportType.COAP; diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java new file mode 100644 index 0000000000..d4c6f85cf1 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java @@ -0,0 +1,36 @@ +/** + * 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.config.transport; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "monitoring.transports.coaps.enabled", havingValue = "true") +@ConfigurationProperties(prefix = "monitoring.transports.coaps") +@Data +@EqualsAndHashCode(callSuper = true) +public class CoapsTransportMonitoringConfig extends CoapTransportMonitoringConfig { + + @Override + public TransportType getTransportType() { + return TransportType.COAPS; + } + +} \ No newline at end of file diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java new file mode 100644 index 0000000000..f7908712c7 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java @@ -0,0 +1,42 @@ +/** + * 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.config.transport; + +import lombok.Data; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +@Data +public class TlsConfig { + + private boolean fetchCertificateChain = false; + + private String truststore; + private String truststorePassword; + + private String keystore; + private String keystorePassword; + private String keystoreKeyAlias; + + public Optional getTruststore() { + return Optional.ofNullable(StringUtils.hasText(truststore) ? truststore : null); + } + + public Optional getKeystore() { + return Optional.ofNullable(StringUtils.hasText(keystore) ? keystore : null); + } +} \ No newline at end of file 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..ba55e870fb 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,11 +28,12 @@ import org.thingsboard.monitoring.service.transport.impl.MqttTransportHealthChec public enum TransportType { MQTT("MQTT", MqttTransportHealthChecker.class), - COAP("CoAP",CoapTransportHealthChecker.class), + COAP("CoAP", CoapTransportHealthChecker.class), + COAPS("CoAPS", CoapTransportHealthChecker.class), HTTP("HTTP", HttpTransportHealthChecker.class), LWM2M("LwM2M", Lwm2mTransportHealthChecker.class); private final String name; private final Class> serviceClass; -} +} \ No newline at end of file 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..fd37fefe0b 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java @@ -83,6 +83,8 @@ public abstract class BaseHealthChecker 0) { + dtlsConfigBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(fetchedCerts) + .build()); + } else if (tlsConfig != null && tlsConfig.getTruststore().isPresent()) { + KeyStore trustStore = SslUtil.loadKeyStore(tlsConfig.getTruststore().get(), tlsConfig.getTruststorePassword()); + List trustCerts = new ArrayList<>(); + for (String alias : Collections.list(trustStore.aliases())) { + if (trustStore.isCertificateEntry(alias)) { + trustCerts.add(trustStore.getCertificate(alias)); + } + } + dtlsConfigBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(trustCerts.toArray(new Certificate[0])) + .build()); + } else { + dtlsConfigBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustAllCertificates() + .build()); + } + coapClient.setEndpoint(new CoapEndpoint.Builder().setConnector(new DTLSConnector(dtlsConfigBuilder.build())).build()); + } + coapClient.setTimeout((long) config.getRequestTimeoutMs()); + log.debug("Connecting {} client to {}", getTransportType(), target.getBaseUrl()); } @Override @@ -62,7 +155,7 @@ public class CoapTransportHealthChecker extends TransportHealthChecker getSupportedCertificateKeyAlgorithms() { + return Collections.singletonList(CipherSuite.CertificateKeyAlgorithm.EC); + } + + @Override + public List getSupportedCertificateTypes() { + return Collections.singletonList(CertificateType.X_509); + } + + @Override + public CertificateIdentityResult requestCertificateIdentity(ConnectionId cid, boolean client, + List issuers, ServerNames serverNames, + List keyAlgorithms, + List signatureAndHashAlgorithms, + List curves) { + return new CertificateIdentityResult(cid); + } + + @Override + public void setResultHandler(HandshakeResultHandler handler) {} } -} +} \ No newline at end of file diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/MqttTransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/MqttTransportHealthChecker.java index 260efe70d6..9fc227ec3a 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/MqttTransportHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/MqttTransportHealthChecker.java @@ -45,21 +45,19 @@ public class MqttTransportHealthChecker extends TransportHealthChecker response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() != 200) { + throw new IOException("Failed to download certificate from " + url + ", status code: " + response.statusCode()); + } + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + @SuppressWarnings("unchecked") + Collection certs = (Collection) cf.generateCertificates(new ByteArrayInputStream(response.body())); + return certs.toArray(new Certificate[0]); + } +} diff --git a/monitoring/src/main/resources/dashboard_cloud_monitoring.json b/monitoring/src/main/resources/dashboard_cloud_monitoring.json index 8be9677c61..dd424cb077 100644 --- a/monitoring/src/main/resources/dashboard_cloud_monitoring.json +++ b/monitoring/src/main/resources/dashboard_cloud_monitoring.json @@ -74,6 +74,20 @@ "usePostProcessing": null, "postFuncBody": null }, + { + "name": "coapsTransportWsUpdateLatency", + "type": "timeseries", + "label": "CoAPS - overall", + "color": "#673ab7", + "settings": {}, + "_hash": 0.5634789012345678, + "aggregationType": null, + "units": "ms", + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, { "name": "httpTransportWsUpdateLatency", "type": "timeseries", @@ -355,6 +369,20 @@ "usePostProcessing": null, "postFuncBody": null }, + { + "name": "coapsTransportRequestLatency", + "type": "timeseries", + "label": "CoAPS transport request latency", + "color": "#e91e63", + "settings": {}, + "_hash": 0.4567890123456789, + "aggregationType": null, + "units": "ms", + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, { "name": "coapIntegrationRequestLatency", "type": "timeseries", diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index 768fd2e4c7..3211c95f32 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -79,6 +79,30 @@ monitoring: # To add more targets, use following environment variables: # monitoring.transports.coap.targets[1].base_url, monitoring.transports.coap.targets[2].base_url, etc. + coaps: + # Enable CoAPS transport checks + enabled: '${COAPS_TRANSPORT_MONITORING_ENABLED:true}' + # CoAPS request timeout in milliseconds + request_timeout_ms: '${COAPS_REQUEST_TIMEOUT_MS:4000}' + tls: + fetchCertificateChain: '${COAPS_FETCH_CERTIFICATE_CHAIN:false}' + truststore: '${COAPS_TRUSTSTORE_PATH:}' + truststorePassword: '${COAPS_TRUSTSTORE_PASSWORD:}' + keystore: '${COAPS_KEYSTORE_PATH:}' + keystorePassword: '${COAPS_KEYSTORE_PASSWORD:}' + keystoreKeyAlias: '${COAPS_KEYSTORE_KEY_ALIAS:}' + targets: + # CoAPS transport base url, coaps://DOMAIN by default + - base_url: '${COAPS_TRANSPORT_BASE_URL:coaps://${monitoring.domain}}' + # Queue to use for target device + queue: '${COAPS_TRANSPORT_USED_QUEUE:Main}' + # Whether to monitor IPs associated with the domain from base url + check_domain_ips: '${COAPS_TRANSPORT_CHECK_DOMAIN_IPS:false}' + # Prefix for the target device name + name_prefix: '${COAPS_TRANSPORT_TARGET_NAME_PREFIX:}' + # To add more targets, use following environment variables: + # monitoring.transports.coaps.targets[1].base_url, monitoring.transports.coaps.targets[2].base_url, etc. + http: # Enable HTTP transport checks enabled: '${HTTP_TRANSPORT_MONITORING_ENABLED:true}' @@ -146,4 +170,4 @@ monitoring: # Latency threshold for notifying threshold_ms: '${LATENCY_THRESHOLD:2000}' # ID of the asset to save latencies to. If not set and latencies reporting is enabled - asset will be found or created automatically - reporting_asset_id: '${LATENCY_REPORTING_ASSET_ID:}' + reporting_asset_id: '${LATENCY_REPORTING_ASSET_ID:}' \ No newline at end of file From c201d2a08fa287780265ca19122a95e034ea74a8 Mon Sep 17 00:00:00 2001 From: pon0marev Date: Thu, 25 Jun 2026 17:40:30 +0300 Subject: [PATCH 02/13] refactor(monitoring): move TlsConfig to CoapsTransportMonitoringConfig and clean up imports --- monitoring/src/main/conf/logback.xml | 1 - .../CoapTransportMonitoringConfig.java | 6 - .../CoapsTransportMonitoringConfig.java | 2 + .../impl/CoapTransportHealthChecker.java | 119 +++++++++--------- .../thingsboard/monitoring/util/SslUtil.java | 1 - 5 files changed, 61 insertions(+), 68 deletions(-) diff --git a/monitoring/src/main/conf/logback.xml b/monitoring/src/main/conf/logback.xml index 269f57b91c..de8b2c3cd5 100644 --- a/monitoring/src/main/conf/logback.xml +++ b/monitoring/src/main/conf/logback.xml @@ -29,7 +29,6 @@ - diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java index 000087e836..761ed227c2 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapTransportMonitoringConfig.java @@ -15,8 +15,6 @@ */ package org.thingsboard.monitoring.config.transport; -import lombok.Data; -import lombok.EqualsAndHashCode; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -24,12 +22,8 @@ import org.springframework.stereotype.Component; @Component @ConditionalOnProperty(name = "monitoring.transports.coap.enabled", havingValue = "true") @ConfigurationProperties(prefix = "monitoring.transports.coap") -@Data -@EqualsAndHashCode(callSuper = true) public class CoapTransportMonitoringConfig extends TransportMonitoringConfig { - private TlsConfig tls; - @Override public TransportType getTransportType() { return TransportType.COAP; diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java index d4c6f85cf1..bffa541ec9 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java @@ -28,6 +28,8 @@ import org.springframework.stereotype.Component; @EqualsAndHashCode(callSuper = true) public class CoapsTransportMonitoringConfig extends CoapTransportMonitoringConfig { + private TlsConfig tls; + @Override public TransportType getTransportType() { return TransportType.COAPS; 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 fa12271a28..9ad2ab5b6e 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 @@ -43,6 +43,7 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; import org.thingsboard.monitoring.config.transport.CoapTransportMonitoringConfig; +import org.thingsboard.monitoring.config.transport.CoapsTransportMonitoringConfig; import org.thingsboard.monitoring.config.transport.TlsConfig; import org.thingsboard.monitoring.config.transport.TransportMonitoringTarget; import org.thingsboard.monitoring.config.transport.TransportType; @@ -51,13 +52,11 @@ import org.thingsboard.monitoring.util.SslUtil; import javax.security.auth.x500.X500Principal; import java.io.IOException; -import java.net.InetSocketAddress; -import java.util.Arrays; import java.security.KeyStore; import java.security.PrivateKey; import java.security.cert.Certificate; -import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -84,68 +83,68 @@ public class CoapTransportHealthChecker extends TransportHealthChecker 0) { - dtlsConfigBuilder.setAdvancedCertificateVerifier( - StaticNewAdvancedCertificateVerifier.builder() - .setTrustedCertificates(fetchedCerts) - .build()); - } else if (tlsConfig != null && tlsConfig.getTruststore().isPresent()) { - KeyStore trustStore = SslUtil.loadKeyStore(tlsConfig.getTruststore().get(), tlsConfig.getTruststorePassword()); - List trustCerts = new ArrayList<>(); - for (String alias : Collections.list(trustStore.aliases())) { - if (trustStore.isCertificateEntry(alias)) { - trustCerts.add(trustStore.getCertificate(alias)); - } + } + if (fetchedCerts != null && fetchedCerts.length > 0) { + dtlsConfigBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(fetchedCerts) + .build()); + } else if (tlsConfig != null && tlsConfig.getTruststore().isPresent()) { + KeyStore trustStore = SslUtil.loadKeyStore(tlsConfig.getTruststore().get(), tlsConfig.getTruststorePassword()); + List trustCerts = new ArrayList<>(); + for (String alias : Collections.list(trustStore.aliases())) { + if (trustStore.isCertificateEntry(alias)) { + trustCerts.add(trustStore.getCertificate(alias)); } - dtlsConfigBuilder.setAdvancedCertificateVerifier( - StaticNewAdvancedCertificateVerifier.builder() - .setTrustedCertificates(trustCerts.toArray(new Certificate[0])) - .build()); - } else { - dtlsConfigBuilder.setAdvancedCertificateVerifier( - StaticNewAdvancedCertificateVerifier.builder() - .setTrustAllCertificates() - .build()); } - coapClient.setEndpoint(new CoapEndpoint.Builder().setConnector(new DTLSConnector(dtlsConfigBuilder.build())).build()); + dtlsConfigBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(trustCerts.toArray(new Certificate[0])) + .build()); + } else { + dtlsConfigBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustAllCertificates() + .build()); } + coapClient.setEndpoint(new CoapEndpoint.Builder().setConnector(new DTLSConnector(dtlsConfigBuilder.build())).build()); + } coapClient.setTimeout((long) config.getRequestTimeoutMs()); log.debug("Connecting {} client to {}", getTransportType(), target.getBaseUrl()); } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/util/SslUtil.java b/monitoring/src/main/java/org/thingsboard/monitoring/util/SslUtil.java index 5bdbedae32..33117e4301 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/util/SslUtil.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/util/SslUtil.java @@ -28,7 +28,6 @@ import java.net.http.HttpResponse; import java.security.KeyStore; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; import java.time.Duration; import java.util.Collection; From eefa598794e4bf9e2d0b84d5b0492b74b88831d9 Mon Sep 17 00:00:00 2001 From: pon0marev Date: Fri, 26 Jun 2026 09:44:38 +0300 Subject: [PATCH 03/13] =?UTF-8?q?refactor(monitoring):=20simplify=20CoAPS?= =?UTF-8?q?=20=E2=80=94=20remove=20TLS=20config=20and=20cert=20download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoapsTransportMonitoringConfig.java | 8 +-- .../config/transport/TlsConfig.java | 42 ------------ .../impl/CoapTransportHealthChecker.java | 63 ++---------------- .../thingsboard/monitoring/util/SslUtil.java | 64 ------------------- .../src/main/resources/tb-monitoring.yml | 7 -- 5 files changed, 5 insertions(+), 179 deletions(-) delete mode 100644 monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java delete mode 100644 monitoring/src/main/java/org/thingsboard/monitoring/util/SslUtil.java diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java index bffa541ec9..68fc8e3564 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java @@ -15,8 +15,6 @@ */ package org.thingsboard.monitoring.config.transport; -import lombok.Data; -import lombok.EqualsAndHashCode; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -24,15 +22,11 @@ import org.springframework.stereotype.Component; @Component @ConditionalOnProperty(name = "monitoring.transports.coaps.enabled", havingValue = "true") @ConfigurationProperties(prefix = "monitoring.transports.coaps") -@Data -@EqualsAndHashCode(callSuper = true) public class CoapsTransportMonitoringConfig extends CoapTransportMonitoringConfig { - private TlsConfig tls; - @Override public TransportType getTransportType() { return TransportType.COAPS; } -} \ No newline at end of file +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java deleted file mode 100644 index f7908712c7..0000000000 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TlsConfig.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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.config.transport; - -import lombok.Data; -import org.springframework.util.StringUtils; - -import java.util.Optional; - -@Data -public class TlsConfig { - - private boolean fetchCertificateChain = false; - - private String truststore; - private String truststorePassword; - - private String keystore; - private String keystorePassword; - private String keystoreKeyAlias; - - public Optional getTruststore() { - return Optional.ofNullable(StringUtils.hasText(truststore) ? truststore : null); - } - - public Optional getKeystore() { - return Optional.ofNullable(StringUtils.hasText(keystore) ? keystore : null); - } -} \ No newline at end of file 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 9ad2ab5b6e..d1f7d8cd6d 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 @@ -35,27 +35,18 @@ import org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm; import org.eclipse.californium.scandium.dtls.cipher.CipherSuite; import org.eclipse.californium.scandium.dtls.cipher.XECDHECryptography; import org.eclipse.californium.scandium.dtls.x509.CertificateProvider; -import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider; import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; import org.eclipse.californium.scandium.util.ServerNames; -import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; import org.thingsboard.monitoring.config.transport.CoapTransportMonitoringConfig; -import org.thingsboard.monitoring.config.transport.CoapsTransportMonitoringConfig; -import org.thingsboard.monitoring.config.transport.TlsConfig; 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.SslUtil; import javax.security.auth.x500.X500Principal; import java.io.IOException; -import java.security.KeyStore; -import java.security.PrivateKey; -import java.security.cert.Certificate; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -70,9 +61,6 @@ public class CoapTransportHealthChecker extends TransportHealthChecker 0) { - dtlsConfigBuilder.setAdvancedCertificateVerifier( - StaticNewAdvancedCertificateVerifier.builder() - .setTrustedCertificates(fetchedCerts) - .build()); - } else if (tlsConfig != null && tlsConfig.getTruststore().isPresent()) { - KeyStore trustStore = SslUtil.loadKeyStore(tlsConfig.getTruststore().get(), tlsConfig.getTruststorePassword()); - List trustCerts = new ArrayList<>(); - for (String alias : Collections.list(trustStore.aliases())) { - if (trustStore.isCertificateEntry(alias)) { - trustCerts.add(trustStore.getCertificate(alias)); - } - } - dtlsConfigBuilder.setAdvancedCertificateVerifier( - StaticNewAdvancedCertificateVerifier.builder() - .setTrustedCertificates(trustCerts.toArray(new Certificate[0])) - .build()); - } else { - dtlsConfigBuilder.setAdvancedCertificateVerifier( - StaticNewAdvancedCertificateVerifier.builder() - .setTrustAllCertificates() - .build()); - } coapClient.setEndpoint(new CoapEndpoint.Builder().setConnector(new DTLSConnector(dtlsConfigBuilder.build())).build()); } coapClient.setTimeout((long) config.getRequestTimeoutMs()); @@ -197,4 +142,4 @@ public class CoapTransportHealthChecker extends TransportHealthChecker response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - throw new IOException("Failed to download certificate from " + url + ", status code: " + response.statusCode()); - } - - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - @SuppressWarnings("unchecked") - Collection certs = (Collection) cf.generateCertificates(new ByteArrayInputStream(response.body())); - return certs.toArray(new Certificate[0]); - } -} diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index 3211c95f32..fa6d83568d 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -84,13 +84,6 @@ monitoring: enabled: '${COAPS_TRANSPORT_MONITORING_ENABLED:true}' # CoAPS request timeout in milliseconds request_timeout_ms: '${COAPS_REQUEST_TIMEOUT_MS:4000}' - tls: - fetchCertificateChain: '${COAPS_FETCH_CERTIFICATE_CHAIN:false}' - truststore: '${COAPS_TRUSTSTORE_PATH:}' - truststorePassword: '${COAPS_TRUSTSTORE_PASSWORD:}' - keystore: '${COAPS_KEYSTORE_PATH:}' - keystorePassword: '${COAPS_KEYSTORE_PASSWORD:}' - keystoreKeyAlias: '${COAPS_KEYSTORE_KEY_ALIAS:}' targets: # CoAPS transport base url, coaps://DOMAIN by default - base_url: '${COAPS_TRANSPORT_BASE_URL:coaps://${monitoring.domain}}' From 79d5ef313a25a092b5135877577e2b4f799c8a30 Mon Sep 17 00:00:00 2001 From: pon0marev Date: Mon, 29 Jun 2026 14:45:19 +0300 Subject: [PATCH 04/13] refactor(monitoring): auto-detect DTLS from CoAP URL scheme, remove separate CoAPS transport --- .../CoapsTransportMonitoringConfig.java | 32 ------------------- .../config/transport/TransportType.java | 1 - .../impl/CoapTransportHealthChecker.java | 2 +- .../resources/dashboard_cloud_monitoring.json | 28 ---------------- .../src/main/resources/tb-monitoring.yml | 23 ++----------- 5 files changed, 4 insertions(+), 82 deletions(-) delete mode 100644 monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java deleted file mode 100644 index 68fc8e3564..0000000000 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/CoapsTransportMonitoringConfig.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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.config.transport; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Component -@ConditionalOnProperty(name = "monitoring.transports.coaps.enabled", havingValue = "true") -@ConfigurationProperties(prefix = "monitoring.transports.coaps") -public class CoapsTransportMonitoringConfig extends CoapTransportMonitoringConfig { - - @Override - public TransportType getTransportType() { - return TransportType.COAPS; - } - -} 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 ba55e870fb..7ef676cb68 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 @@ -29,7 +29,6 @@ public enum TransportType { MQTT("MQTT", MqttTransportHealthChecker.class), COAP("CoAP", CoapTransportHealthChecker.class), - COAPS("CoAPS", CoapTransportHealthChecker.class), HTTP("HTTP", HttpTransportHealthChecker.class), LWM2M("LwM2M", Lwm2mTransportHealthChecker.class); 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 d1f7d8cd6d..6c400c5f1a 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 @@ -72,7 +72,7 @@ public class CoapTransportHealthChecker extends TransportHealthChecker Date: Mon, 29 Jun 2026 17:21:53 +0300 Subject: [PATCH 05/13] fix(monitoring): fix typo in CoAP base_url comment example --- monitoring/src/main/resources/tb-monitoring.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index e0d28e25ef..a9a67398f2 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -68,7 +68,7 @@ monitoring: # CoAP request timeout in milliseconds request_timeout_ms: '${COAP_REQUEST_TIMEOUT_MS:4000}' targets: - # CoAP transport base url, coap://DOMAIN by default (use coaps:// for DTLS). Specify a custom port, e.g. tcp://DOMAIN:5683 + # CoAP transport base url, coap://DOMAIN by default (use coaps:// for DTLS). Specify a custom port, e.g. coap://DOMAIN:5683 - base_url: '${COAP_TRANSPORT_BASE_URL:coap://${monitoring.domain}}' # Queue to use for target device queue: '${COAP_TRANSPORT_USED_QUEUE:Main}' From e560f764aaf3e85099c57c981c9ad3d7545420cd Mon Sep 17 00:00:00 2001 From: pon0marev Date: Mon, 29 Jun 2026 18:17:00 +0300 Subject: [PATCH 06/13] refactor(monitoring): improve transport base_url comments with port defaults and TLS examples --- .../transport/impl/CoapTransportHealthChecker.java | 2 +- monitoring/src/main/resources/tb-monitoring.yml | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) 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 6c400c5f1a..d331db2871 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 @@ -121,7 +121,7 @@ public class CoapTransportHealthChecker extends TransportHealthChecker getSupportedCertificateKeyAlgorithms() { - return Collections.singletonList(CipherSuite.CertificateKeyAlgorithm.EC); + return Arrays.asList(CipherSuite.CertificateKeyAlgorithm.EC, CipherSuite.CertificateKeyAlgorithm.RSA); } @Override diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index a9a67398f2..8b9332f4c5 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -51,7 +51,8 @@ monitoring: # MQTT QoS qos: '${MQTT_QOS_LEVEL:1}' targets: - # MQTT transport base url, tcp://DOMAIN by default (use ssl:// for TLS). Specify a custom port, e.g. tcp://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}' @@ -68,7 +69,8 @@ monitoring: # CoAP request timeout in milliseconds request_timeout_ms: '${COAP_REQUEST_TIMEOUT_MS:4000}' targets: - # CoAP transport base url, coap://DOMAIN by default (use coaps:// for DTLS). Specify a custom port, e.g. coap://DOMAIN:5683 + # 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 +87,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 +105,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 is optional — if omitted, defaults by scheme (coap: 5685, coaps: 5686). + # To enable DTLS, change scheme to coaps://, e.g. coaps://DOMAIN or coaps://DOMAIN:5686 - base_url: '${LWM2M_TRANSPORT_BASE_URL:coap://${monitoring.domain}:5685}' # Queue to use for target device queue: '${LWM2M_TRANSPORT_USED_QUEUE:Main}' From 87770a1700ba6c47f4eced56caa690779419413c Mon Sep 17 00:00:00 2001 From: pon0marev Date: Mon, 29 Jun 2026 18:44:09 +0300 Subject: [PATCH 07/13] fix(monitoring): fix DTLSConnector leak and prevent exception swallowing in CoAP client cleanup --- .../impl/CoapTransportHealthChecker.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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 d331db2871..b398b837f2 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 @@ -62,6 +62,7 @@ public class CoapTransportHealthChecker extends TransportHealthChecker Date: Tue, 30 Jun 2026 15:51:06 +0300 Subject: [PATCH 08/13] feat(monitoring): add transport session reuse with configurable session duration --- .../ThingsboardMonitoringApplication.java | 4 +- .../monitoring/service/BaseHealthChecker.java | 13 ++++- .../transport/TransportHealthChecker.java | 12 ++++ .../impl/CoapTransportHealthChecker.java | 55 ++++++++++++------- .../impl/MqttTransportHealthChecker.java | 35 ++++++++---- .../src/main/resources/tb-monitoring.yml | 3 + 6 files changed, 88 insertions(+), 34 deletions(-) diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java b/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java index 9218b78bea..bb92c97ece 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:3600000}") + 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/service/BaseHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java index fd37fefe0b..2378de7051 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java @@ -82,9 +82,8 @@ public abstract class BaseHealthChecker 0 && System.currentTimeMillis() - sessionStartTime >= sessionDurationMs; + } + + protected void recordSessionStart() { + sessionStartTime = System.currentTimeMillis(); + } 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 b398b837f2..571e2e74ea 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 @@ -48,7 +48,6 @@ import org.thingsboard.monitoring.service.transport.TransportHealthChecker; import javax.security.auth.x500.X500Principal; import java.io.IOException; import java.util.Arrays; -import java.util.Collections; import java.util.List; @Component @@ -70,6 +69,15 @@ public class CoapTransportHealthChecker extends TransportHealthChecker getSupportedCertificateKeyAlgorithms() { - return Arrays.asList(CipherSuite.CertificateKeyAlgorithm.EC, CipherSuite.CertificateKeyAlgorithm.RSA); + return List.of(CipherSuite.CertificateKeyAlgorithm.EC, CipherSuite.CertificateKeyAlgorithm.RSA); } @Override public List getSupportedCertificateTypes() { - return Collections.singletonList(CertificateType.X_509); + return List.of(CertificateType.X_509); } @Override @@ -157,4 +172,4 @@ public class CoapTransportHealthChecker extends TransportHealthChecker Date: Wed, 1 Jul 2026 09:29:47 +0300 Subject: [PATCH 09/13] fix(monitoring): use JVM trust store for CoAPS certificate verification instead of trusting all --- .../impl/CoapTransportHealthChecker.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 571e2e74ea..22a1e79b9e 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 @@ -45,8 +45,12 @@ import org.thingsboard.monitoring.config.transport.TransportMonitoringTarget; import org.thingsboard.monitoring.config.transport.TransportType; import org.thingsboard.monitoring.service.transport.TransportHealthChecker; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; import javax.security.auth.x500.X500Principal; import java.io.IOException; +import java.security.KeyStore; +import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.List; @@ -55,9 +59,19 @@ import java.util.List; @Slf4j public class CoapTransportHealthChecker extends TransportHealthChecker { + private static final X509Certificate[] TRUSTED_CERTS; + static { SystemConfig.register(); CoapConfig.register(); + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((KeyStore) null); + TRUSTED_CERTS = ((X509TrustManager) tmf.getTrustManagers()[0]).getAcceptedIssuers(); + log.debug("Loaded {} trusted certificates from JVM trust store", TRUSTED_CERTS.length); + } catch (Exception e) { + throw new ExceptionInInitializerError(e); + } } private CoapClient coapClient; @@ -95,7 +109,7 @@ public class CoapTransportHealthChecker extends TransportHealthChecker Date: Fri, 3 Jul 2026 12:51:02 +0300 Subject: [PATCH 10/13] fix(monitoring): restore wildcard-aware CoAPS hostname verification Disabling DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT let the client accept any publicly-trusted certificate for any hostname (MITM-able). Scandium's own subject check can't be re-enabled as-is since it doesn't support wildcard SANs, which real deployments use. WildcardAwareCertificateVerifier restores real hostname verification by delegating to Apache HttpClient5's DefaultHostnameVerifier (SAN/wildcard/ IPv4/IPv6 matching, backed by a real Public Suffix List) instead of hand-rolling certificate-matching logic, with a small additional guard against wildcards spanning multi-label public suffixes that the library itself doesn't cover. Trust-store loading moves to SslUtil per review feedback, and the now-unnecessary anonymous client certificate identity provider is removed since a CLIENT_ONLY DTLS role never presents one. --- common/util/pom.xml | 8 + .../org/thingsboard/common/util/SslUtil.java | 41 ++++ .../WildcardAwareCertificateVerifier.java | 191 ++++++++++++++++++ .../WildcardAwareCertificateVerifierTest.java | 158 +++++++++++++++ .../impl/CoapTransportHealthChecker.java | 76 +------ 5 files changed, 401 insertions(+), 73 deletions(-) create mode 100644 common/util/src/main/java/org/thingsboard/common/util/WildcardAwareCertificateVerifier.java create mode 100644 common/util/src/test/java/org/thingsboard/common/util/WildcardAwareCertificateVerifierTest.java diff --git a/common/util/pom.xml b/common/util/pom.xml index f3077d49c3..e326c74e40 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -123,6 +123,14 @@ com.github.ben-manes.caffeine caffeine + + org.eclipse.californium + scandium + + + org.apache.httpcomponents.client5 + httpclient5 + 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..1001b5ffa1 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 @@ -31,12 +31,21 @@ import org.bouncycastle.operator.InputDecryptorProvider; import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; import org.bouncycastle.pkcs.PKCSException; import org.bouncycastle.pkcs.jcajce.JcePKCSPBEInputDecryptorProviderBuilder; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.elements.config.SystemConfig; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConfig; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm; 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; @@ -51,10 +60,23 @@ public class SslUtil { public static final BouncyCastleProvider DEFAULT_PROVIDER = new BouncyCastleProvider(); + // Californium only exposes named constants for SHA256_WITH_ECDSA/SHA384_WITH_ECDSA/SHA256_WITH_RSA; + // the other RSA/ECDSA combinations below have no named constant in this version and must be + // looked up by name instead. + private static final List 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); + static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(DEFAULT_PROVIDER); } + SystemConfig.register(); } private SslUtil() { @@ -134,4 +156,23 @@ 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(); + } + + public static DTLSConnector defaultDtlsClientConnector() { + Configuration config = new Configuration(); + 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); + // 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(getDefaultTrustedCertificates())) + .build()); + } + } diff --git a/common/util/src/main/java/org/thingsboard/common/util/WildcardAwareCertificateVerifier.java b/common/util/src/main/java/org/thingsboard/common/util/WildcardAwareCertificateVerifier.java new file mode 100644 index 0000000000..527f4b2c74 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/WildcardAwareCertificateVerifier.java @@ -0,0 +1,191 @@ +/** + * 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.common.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/hostname check ({@code DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT}) + * matches SAN entries with a plain {@code equalsIgnoreCase}, so it rejects wildcard + * certificates (e.g. {@code *.example.com}) outright. This verifier delegates chain-of-trust + * validation to {@link StaticNewAdvancedCertificateVerifier} (with its subject check disabled) + * and delegates the actual hostname/IP identity check to Apache HttpClient's + * {@link DefaultHostnameVerifier} (RFC 2818/6125 SAN + wildcard + IPv4/IPv6 matching), constructed + * with a real {@link PublicSuffixMatcher} so its own built-in public-suffix rejection is active + * (the no-arg constructor would otherwise silently disable it) instead of re-implementing + * certificate-matching logic. + *

+ * The library's public-suffix check only rejects a wildcard when its suffix is itself a bare + * public suffix (e.g. {@code *.com}) — it doesn't reject multi-label public suffixes such as + * {@code *.co.uk} or {@code *.github.io} (verified empirically; guarded by + * {@code verifySubject_overlyBroadWildcard_returnsException} so a future httpclient5 upgrade that + * changes this would fail the build rather than regress silently). {@link #matchesOverlyBroadWildcard} + * closes that specific gap, scoped to only the SAN entry that would actually match the destination + * (so an unrelated overly-broad SAN elsewhere on a multi-domain certificate can't cause a false + * rejection). + */ +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.delegate = StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(trustedCertificates) + .build(); + } + + @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) { + 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()); + } + } + + /** + * {@link DefaultHostnameVerifier} (even with a real {@link PublicSuffixMatcher}) only rejects + * a wildcard when its suffix is itself a bare public suffix (e.g. {@code *.com}) — it doesn't + * reject multi-label public suffixes such as {@code *.co.uk} or {@code *.github.io}. Only the + * SAN entry that would actually be used to match {@code destination} is checked, so an + * unrelated overly-broad wildcard elsewhere on a multi-domain certificate can't cause a false + * rejection of an otherwise valid match. + */ + 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/common/util/src/test/java/org/thingsboard/common/util/WildcardAwareCertificateVerifierTest.java b/common/util/src/test/java/org/thingsboard/common/util/WildcardAwareCertificateVerifierTest.java new file mode 100644 index 0000000000..dfabfe47bd --- /dev/null +++ b/common/util/src/test/java/org/thingsboard/common/util/WildcardAwareCertificateVerifierTest.java @@ -0,0 +1,158 @@ +/** + * 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.common.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.HandshakeException; +import org.eclipse.californium.scandium.util.ServerNames; +import org.junit.jupiter.api.Test; + +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.X509Certificate; +import java.time.Instant; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; + +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(); + } + + 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)); + } + +} \ No newline at end of file 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 22a1e79b9e..2c26cd4ad1 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 @@ -22,56 +22,26 @@ 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.Configuration; import org.eclipse.californium.elements.config.SystemConfig; -import org.eclipse.californium.scandium.DTLSConnector; -import org.eclipse.californium.scandium.config.DtlsConfig; -import org.eclipse.californium.scandium.config.DtlsConnectorConfig; -import org.eclipse.californium.scandium.dtls.CertificateIdentityResult; -import org.eclipse.californium.scandium.dtls.CertificateType; -import org.eclipse.californium.scandium.dtls.ConnectionId; -import org.eclipse.californium.scandium.dtls.HandshakeResultHandler; -import org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm; -import org.eclipse.californium.scandium.dtls.cipher.CipherSuite; -import org.eclipse.californium.scandium.dtls.cipher.XECDHECryptography; -import org.eclipse.californium.scandium.dtls.x509.CertificateProvider; -import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; -import org.eclipse.californium.scandium.util.ServerNames; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SslUtil; 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 javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; -import javax.security.auth.x500.X500Principal; import java.io.IOException; -import java.security.KeyStore; -import java.security.cert.X509Certificate; -import java.util.Arrays; -import java.util.List; @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) @Slf4j public class CoapTransportHealthChecker extends TransportHealthChecker { - private static final X509Certificate[] TRUSTED_CERTS; - static { SystemConfig.register(); CoapConfig.register(); - try { - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init((KeyStore) null); - TRUSTED_CERTS = ((X509TrustManager) tmf.getTrustManagers()[0]).getAcceptedIssuers(); - log.debug("Loaded {} trusted certificates from JVM trust store", TRUSTED_CERTS.length); - } catch (Exception e) { - throw new ExceptionInInitializerError(e); - } } private CoapClient coapClient; @@ -96,22 +66,7 @@ public class CoapTransportHealthChecker extends TransportHealthChecker getSupportedCertificateKeyAlgorithms() { - return List.of(CipherSuite.CertificateKeyAlgorithm.EC, CipherSuite.CertificateKeyAlgorithm.RSA); - } - - @Override - public List getSupportedCertificateTypes() { - return List.of(CertificateType.X_509); - } - - @Override - public CertificateIdentityResult requestCertificateIdentity(ConnectionId cid, boolean client, - List issuers, ServerNames serverNames, - List keyAlgorithms, - List signatureAndHashAlgorithms, - List curves) { - return new CertificateIdentityResult(cid); - } - - @Override - public void setResultHandler(HandshakeResultHandler handler) {} - } - -} \ No newline at end of file +} From 14cb5103a18b2f3a1b1762ef45afc09266249b07 Mon Sep 17 00:00:00 2001 From: pon0marev Date: Tue, 7 Jul 2026 16:31:57 +0300 Subject: [PATCH 11/13] fix(monitoring): address CoAPS PR review feedback Moves the DTLS/wildcard-verification code out of common/util into monitoring (the only consumer), so the shared module doesn't pull in Californium and httpclient5. CoAP secure-vs-plain detection now goes through CoAP.isSecureScheme(CoAP.getSchemeFromUri(...)) instead of a raw startsWith("coaps") check, and the CoAP/MQTT transport health checkers now share a single reconnect-choreography helper instead of duplicating it. Also, per review: - guards WildcardAwareCertificateVerifier against an empty CertPath (delegate skipped validation entirely) instead of NPE-ing on it - switches session-expiry tracking to System.nanoTime() so NTP/clock adjustments can't shorten or extend a session - preserves exception stack traces in client-teardown warning logs instead of logging only getMessage() - removes a dead, exception-throwing block in Lwm2mClient that computed an unused Configuration and crashed on port-less target URLs - corrects the LwM2M yml docs: the port is not auto-defaulted like the other transports, and DTLS isn't supported since the client always registers with noSec regardless of scheme - fixes "Connecting" being logged after the client had already connected - adds test coverage for the verifyCertificate() override (including the CertificateParsingException fail-closed path) and for session expiry Verified with a live deployment against a real wildcard-cert CoAPS server, exercising all four transports end to end including session-expiry reconnects. --- common/util/pom.xml | 8 -- .../org/thingsboard/common/util/SslUtil.java | 31 ----- monitoring/pom.xml | 4 + .../ThingsboardMonitoringApplication.java | 2 +- .../monitoring/client/Lwm2mClient.java | 7 -- .../config/transport/TransportType.java | 2 +- .../monitoring/service/BaseHealthChecker.java | 4 +- .../transport/TransportHealthChecker.java | 23 +++- .../impl/CoapTransportHealthChecker.java | 42 ++++--- .../impl/MqttTransportHealthChecker.java | 41 +++---- .../util/DtlsClientConnectorFactory.java | 72 ++++++++++++ .../WildcardAwareCertificateVerifier.java | 47 +++----- .../src/main/resources/tb-monitoring.yml | 8 +- .../transport/TransportHealthCheckerTest.java | 68 +++++++++++ .../WildcardAwareCertificateVerifierTest.java | 108 +++++++++++++++++- 15 files changed, 334 insertions(+), 133 deletions(-) create mode 100644 monitoring/src/main/java/org/thingsboard/monitoring/util/DtlsClientConnectorFactory.java rename {common/util/src/main/java/org/thingsboard/common => monitoring/src/main/java/org/thingsboard/monitoring}/util/WildcardAwareCertificateVerifier.java (76%) create mode 100644 monitoring/src/test/java/org/thingsboard/monitoring/service/transport/TransportHealthCheckerTest.java rename {common/util/src/test/java/org/thingsboard/common => monitoring/src/test/java/org/thingsboard/monitoring}/util/WildcardAwareCertificateVerifierTest.java (52%) diff --git a/common/util/pom.xml b/common/util/pom.xml index e326c74e40..f3077d49c3 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -123,14 +123,6 @@ com.github.ben-manes.caffeine caffeine - - org.eclipse.californium - scandium - - - org.apache.httpcomponents.client5 - httpclient5 - 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 1001b5ffa1..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 @@ -31,12 +31,6 @@ import org.bouncycastle.operator.InputDecryptorProvider; import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; import org.bouncycastle.pkcs.PKCSException; import org.bouncycastle.pkcs.jcajce.JcePKCSPBEInputDecryptorProviderBuilder; -import org.eclipse.californium.elements.config.Configuration; -import org.eclipse.californium.elements.config.SystemConfig; -import org.eclipse.californium.scandium.DTLSConnector; -import org.eclipse.californium.scandium.config.DtlsConfig; -import org.eclipse.californium.scandium.config.DtlsConnectorConfig; -import org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm; import org.thingsboard.server.common.data.StringUtils; import javax.net.ssl.TrustManagerFactory; @@ -60,23 +54,10 @@ public class SslUtil { public static final BouncyCastleProvider DEFAULT_PROVIDER = new BouncyCastleProvider(); - // Californium only exposes named constants for SHA256_WITH_ECDSA/SHA384_WITH_ECDSA/SHA256_WITH_RSA; - // the other RSA/ECDSA combinations below have no named constant in this version and must be - // looked up by name instead. - private static final List 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); - static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(DEFAULT_PROVIDER); } - SystemConfig.register(); } private SslUtil() { @@ -163,16 +144,4 @@ public class SslUtil { return ((X509TrustManager) tmf.getTrustManagers()[0]).getAcceptedIssuers(); } - public static DTLSConnector defaultDtlsClientConnector() { - Configuration config = new Configuration(); - 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); - // 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(getDefaultTrustedCertificates())) - .build()); - } - } 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/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java b/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java index bb92c97ece..3eec18bf13 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java @@ -49,7 +49,7 @@ public class ThingsboardMonitoringApplication { @Value("${monitoring.monitoring_rate_ms}") private int monitoringRateMs; - @Value("${monitoring.session_duration_ms:3600000}") + @Value("${monitoring.session_duration_ms}") private long sessionDurationMs; ScheduledExecutorService scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("monitoring"); 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 7ef676cb68..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 @@ -35,4 +35,4 @@ public enum TransportType { private final String name; private final Class> serviceClass; -} \ No newline at end of file +} 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 2378de7051..9f30efdad5 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java @@ -129,7 +129,7 @@ public abstract class BaseHealthChecker extends BaseHealthChecker { @@ -33,14 +35,29 @@ public abstract class TransportHealthChecker 0 && System.currentTimeMillis() - sessionStartTime >= sessionDurationMs; + return sessionDurationMs > 0 && System.nanoTime() - sessionStartTimeNanos >= TimeUnit.MILLISECONDS.toNanos(sessionDurationMs); } protected void recordSessionStart() { - sessionStartTime = System.currentTimeMillis(); + 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) { 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 2c26cd4ad1..976ccbc5b1 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 @@ -26,11 +26,11 @@ import org.eclipse.californium.elements.config.SystemConfig; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; -import org.thingsboard.common.util.SslUtil; 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; @@ -53,25 +53,19 @@ 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()); + }); } @Override @@ -96,13 +90,13 @@ public class CoapTransportHealthChecker extends TransportHealthChecker { + String clientId = MqttAsyncClient.generateClientId(); + String accessToken = target.getDevice().getCredentials().getCredentialsId(); + mqttClient = new MqttClient(target.getBaseUrl(), clientId, new MemoryPersistence()); + mqttClient.setTimeToWait(config.getRequestTimeoutMs()); - String clientId = MqttAsyncClient.generateClientId(); - String accessToken = target.getDevice().getCredentials().getCredentialsId(); - mqttClient = new MqttClient(target.getBaseUrl(), clientId, new MemoryPersistence()); - mqttClient.setTimeToWait(config.getRequestTimeoutMs()); - - MqttConnectOptions options = new MqttConnectOptions(); - options.setUserName(accessToken); - options.setConnectionTimeout(config.getRequestTimeoutMs() / 1000); - IMqttToken result = mqttClient.connectWithResult(options); - if (result.getException() != null) { - throw result.getException(); - } - recordSessionStart(); - log.debug("Connecting {} client to {}", getTransportType(), target.getBaseUrl()); + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + options.setConnectionTimeout(config.getRequestTimeoutMs() / 1000); + IMqttToken result = mqttClient.connectWithResult(options); + if (result.getException() != null) { + throw result.getException(); + } + }); } @Override @@ -90,12 +85,12 @@ 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); + + private static final X509Certificate[] TRUSTED_CERTIFICATES = SslUtil.getDefaultTrustedCertificates(); + + static { + SystemConfig.register(); + CoapConfig.register(); + } + + private DtlsClientConnectorFactory() { + } + + public static DTLSConnector jvmTrustedDtlsClientConnector() { + Configuration config = new Configuration(); + 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); + // 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/common/util/src/main/java/org/thingsboard/common/util/WildcardAwareCertificateVerifier.java b/monitoring/src/main/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifier.java similarity index 76% rename from common/util/src/main/java/org/thingsboard/common/util/WildcardAwareCertificateVerifier.java rename to monitoring/src/main/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifier.java index 527f4b2c74..f617502848 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/WildcardAwareCertificateVerifier.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifier.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.common.util; +package org.thingsboard.monitoring.util; import org.apache.hc.client5.http.psl.PublicSuffixMatcher; import org.apache.hc.client5.http.psl.PublicSuffixMatcherLoader; @@ -42,24 +42,12 @@ import java.util.Collection; import java.util.List; /** - * Scandium's own subject/hostname check ({@code DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT}) - * matches SAN entries with a plain {@code equalsIgnoreCase}, so it rejects wildcard - * certificates (e.g. {@code *.example.com}) outright. This verifier delegates chain-of-trust - * validation to {@link StaticNewAdvancedCertificateVerifier} (with its subject check disabled) - * and delegates the actual hostname/IP identity check to Apache HttpClient's - * {@link DefaultHostnameVerifier} (RFC 2818/6125 SAN + wildcard + IPv4/IPv6 matching), constructed - * with a real {@link PublicSuffixMatcher} so its own built-in public-suffix rejection is active - * (the no-arg constructor would otherwise silently disable it) instead of re-implementing - * certificate-matching logic. - *

- * The library's public-suffix check only rejects a wildcard when its suffix is itself a bare - * public suffix (e.g. {@code *.com}) — it doesn't reject multi-label public suffixes such as - * {@code *.co.uk} or {@code *.github.io} (verified empirically; guarded by - * {@code verifySubject_overlyBroadWildcard_returnsException} so a future httpclient5 upgrade that - * changes this would fail the build rather than regress silently). {@link #matchesOverlyBroadWildcard} - * closes that specific gap, scoped to only the SAN entry that would actually match the destination - * (so an unrelated overly-broad SAN elsewhere on a multi-domain certificate can't cause a false - * rejection). + * 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 { @@ -72,9 +60,13 @@ class WildcardAwareCertificateVerifier implements NewAdvancedCertificateVerifier private final NewAdvancedCertificateVerifier delegate; WildcardAwareCertificateVerifier(X509Certificate[] trustedCertificates) { - this.delegate = StaticNewAdvancedCertificateVerifier.builder() + this(StaticNewAdvancedCertificateVerifier.builder() .setTrustedCertificates(trustedCertificates) - .build(); + .build()); + } + + WildcardAwareCertificateVerifier(NewAdvancedCertificateVerifier delegate) { + this.delegate = delegate; } @Override @@ -87,7 +79,8 @@ class WildcardAwareCertificateVerifier implements NewAdvancedCertificateVerifier 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) { + if (!verifySubject || result.getException() != null + || result.getCertificatePath() == null || result.getCertificatePath().getCertificates().isEmpty()) { return result; } @@ -122,14 +115,8 @@ class WildcardAwareCertificateVerifier implements NewAdvancedCertificateVerifier } } - /** - * {@link DefaultHostnameVerifier} (even with a real {@link PublicSuffixMatcher}) only rejects - * a wildcard when its suffix is itself a bare public suffix (e.g. {@code *.com}) — it doesn't - * reject multi-label public suffixes such as {@code *.co.uk} or {@code *.github.io}. Only the - * SAN entry that would actually be used to match {@code destination} is checked, so an - * unrelated overly-broad wildcard elsewhere on a multi-domain certificate can't cause a false - * rejection of an otherwise valid match. - */ + // 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) { diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index 9b68d71fad..8454d38862 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -38,6 +38,8 @@ monitoring: 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 @@ -108,8 +110,8 @@ monitoring: # LwM2M request timeout in milliseconds request_timeout_ms: '${LWM2M_REQUEST_TIMEOUT_MS:4000}' targets: - # LwM2M transport base url. Port is optional — if omitted, defaults by scheme (coap: 5685, coaps: 5686). - # To enable DTLS, change scheme to coaps://, e.g. coaps://DOMAIN or coaps://DOMAIN:5686 + # 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}' @@ -153,4 +155,4 @@ monitoring: # Latency threshold for notifying threshold_ms: '${LATENCY_THRESHOLD:2000}' # ID of the asset to save latencies to. If not set and latencies reporting is enabled - asset will be found or created automatically - reporting_asset_id: '${LATENCY_REPORTING_ASSET_ID:}' \ No newline at end of file + reporting_asset_id: '${LATENCY_REPORTING_ASSET_ID:}' 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/common/util/src/test/java/org/thingsboard/common/util/WildcardAwareCertificateVerifierTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifierTest.java similarity index 52% rename from common/util/src/test/java/org/thingsboard/common/util/WildcardAwareCertificateVerifierTest.java rename to monitoring/src/test/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifierTest.java index dfabfe47bd..a002119057 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/WildcardAwareCertificateVerifierTest.java +++ b/monitoring/src/test/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifierTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.common.util; +package org.thingsboard.monitoring.util; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.Extension; @@ -24,21 +24,36 @@ 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 { @@ -131,6 +146,95 @@ class WildcardAwareCertificateVerifierTest { 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)); } @@ -155,4 +259,4 @@ class WildcardAwareCertificateVerifierTest { return new JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(builder.build(signer)); } -} \ No newline at end of file +} From 97831a32bb1c6333003088d6043b5b9384f2edd7 Mon Sep 17 00:00:00 2001 From: pon0marev Date: Thu, 9 Jul 2026 12:22:46 +0300 Subject: [PATCH 12/13] fix(monitoring): add RSA cipher suites for CoAPS DTLS client Without an explicit cipher suite list, Scandium restricts a cert-verifier-only client to ECDSA suites, so CoAPS servers with an RSA-only certificate fail the handshake with no common cipher suite. --- .../monitoring/util/DtlsClientConnectorFactory.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/util/DtlsClientConnectorFactory.java b/monitoring/src/main/java/org/thingsboard/monitoring/util/DtlsClientConnectorFactory.java index 4ffadf8197..da6e0df797 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/util/DtlsClientConnectorFactory.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/util/DtlsClientConnectorFactory.java @@ -22,6 +22,7 @@ import org.eclipse.californium.scandium.DTLSConnector; import org.eclipse.californium.scandium.config.DtlsConfig; import org.eclipse.californium.scandium.config.DtlsConnectorConfig; import org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm; +import org.eclipse.californium.scandium.dtls.cipher.CipherSuite; import org.thingsboard.common.util.SslUtil; import java.security.cert.X509Certificate; @@ -47,6 +48,17 @@ public class DtlsClientConnectorFactory { 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 { @@ -62,6 +74,7 @@ public class DtlsClientConnectorFactory { 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) From bf54101328997a12e63eedd14080a5cdd0648e76 Mon Sep 17 00:00:00 2001 From: pon0marev Date: Thu, 9 Jul 2026 18:13:24 +0300 Subject: [PATCH 13/13] fix(monitoring): register DtlsConfig and build DTLS config from Californium standard defaults new Configuration() started from an empty config with only SystemConfig/CoapConfig registered, leaving DTLS module defaults unset. Register DtlsConfig and build from Configuration.getStandard() so unset DTLS properties fall back to Californium's own defaults instead of remaining unspecified. --- .../service/transport/impl/CoapTransportHealthChecker.java | 2 ++ .../monitoring/util/DtlsClientConnectorFactory.java | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) 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 976ccbc5b1..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 @@ -23,6 +23,7 @@ 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; @@ -42,6 +43,7 @@ public class CoapTransportHealthChecker extends TransportHealthChecker