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