Andrii Ponomarov 2 weeks ago
committed by GitHub
parent
commit
41d68d02e4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      common/util/src/main/java/org/thingsboard/common/util/SslUtil.java
  2. 4
      monitoring/pom.xml
  3. 1
      monitoring/src/main/conf/logback.xml
  4. 4
      monitoring/src/main/java/org/thingsboard/monitoring/ThingsboardMonitoringApplication.java
  5. 7
      monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java
  6. 2
      monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportType.java
  7. 9
      monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java
  8. 29
      monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java
  9. 52
      monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/CoapTransportHealthChecker.java
  10. 28
      monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/MqttTransportHealthChecker.java
  11. 86
      monitoring/src/main/java/org/thingsboard/monitoring/util/DtlsClientConnectorFactory.java
  12. 178
      monitoring/src/main/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifier.java
  13. 19
      monitoring/src/main/resources/tb-monitoring.yml
  14. 68
      monitoring/src/test/java/org/thingsboard/monitoring/service/transport/TransportHealthCheckerTest.java
  15. 262
      monitoring/src/test/java/org/thingsboard/monitoring/util/WildcardAwareCertificateVerifierTest.java

10
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();
}
}

4
monitoring/pom.xml

@ -79,6 +79,10 @@
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId>

1
monitoring/src/main/conf/logback.xml

@ -29,6 +29,7 @@
<logger name="org.thingsboard.server" level="INFO"/>
<logger name="org.thingsboard.monitoring" level="INFO"/>
<logger name="org.thingsboard.monitoring.client" level="WARN"/>
<logger name="org.eclipse.californium.scandium" level="WARN"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>

4
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);
}

7
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);

2
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);

9
monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java

@ -82,6 +82,7 @@ public abstract class BaseHealthChecker<C extends MonitoringConfig, T extends Mo
reporter.reportLatency(Latencies.request(getKey()), stopWatch.getTime());
log.trace("[{}] Sent test payload ({})", info, testPayload);
} catch (Throwable e) {
safeDestroyClient();
throw new ServiceFailureException(info, e);
}
@ -124,6 +125,14 @@ public abstract class BaseHealthChecker<C extends MonitoringConfig, T extends Mo
reporter.reportLatency(Latencies.wsUpdate(getKey()), stopWatch.getTime());
}
private void safeDestroyClient() {
try {
destroyClient();
} catch (Throwable e) {
log.warn("[{}] Failed to destroy client", info, e);
}
}
protected abstract void initClient() throws Exception;
protected abstract String createTestPayload(String testValue);

29
monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java

@ -25,11 +25,40 @@ import org.thingsboard.monitoring.config.transport.TransportMonitoringTarget;
import org.thingsboard.monitoring.config.transport.TransportType;
import org.thingsboard.monitoring.service.BaseHealthChecker;
import java.util.concurrent.TimeUnit;
@Slf4j
public abstract class TransportHealthChecker<C extends TransportMonitoringConfig> extends BaseHealthChecker<C, TransportMonitoringTarget> {
@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);

52
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<CoapTrans
static {
SystemConfig.register();
CoapConfig.register();
DtlsConfig.register();
}
private CoapClient coapClient;
private CoapEndpoint coapEndpoint;
protected CoapTransportHealthChecker(CoapTransportMonitoringConfig config, TransportMonitoringTarget target) {
super(config, target);
@ -48,30 +55,55 @@ public class CoapTransportHealthChecker extends TransportHealthChecker<CoapTrans
@Override
protected void initClient() throws Exception {
if (coapClient == null) {
if (coapClient != null && !isSessionExpired()) {
return;
}
reconnectIfNeeded(coapClient != null, this::shutdownCoapClient, () -> {
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<CoapTrans
return TransportType.COAP;
}
private boolean isSecure() {
return CoAP.isSecureScheme(CoAP.getSchemeFromUri(target.getBaseUrl()));
}
}

28
monitoring/src/main/java/org/thingsboard/monitoring/service/transport/impl/MqttTransportHealthChecker.java

@ -45,7 +45,10 @@ public class MqttTransportHealthChecker extends TransportHealthChecker<MqttTrans
@Override
protected void initClient() throws Exception {
if (mqttClient == null || !mqttClient.isConnected()) {
if (mqttClient != null && mqttClient.isConnected() && !isSessionExpired()) {
return;
}
reconnectIfNeeded(mqttClient != null, this::closeMqttClient, () -> {
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<MqttTrans
if (result.getException() != null) {
throw result.getException();
}
log.debug("Initialized MQTT client for URI {}", mqttClient.getServerURI());
}
});
}
@Override
@ -73,9 +75,25 @@ public class MqttTransportHealthChecker extends TransportHealthChecker<MqttTrans
@Override
protected void destroyClient() throws Exception {
if (mqttClient != null) {
mqttClient.disconnect();
closeMqttClient();
}
}
private void closeMqttClient() {
try {
if (mqttClient.isConnected()) {
mqttClient.disconnect();
}
} catch (Exception e) {
log.warn("Failed to disconnect MQTT client", e);
} finally {
try {
mqttClient.close();
} catch (Exception e) {
log.warn("Failed to close MQTT client", e);
}
mqttClient = null;
log.info("Disconnected MQTT client");
log.debug("Disconnected {} client", getTransportType());
}
}

86
monitoring/src/main/java/org/thingsboard/monitoring/util/DtlsClientConnectorFactory.java

@ -0,0 +1,86 @@
/**
* 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.eclipse.californium.core.config.CoapConfig;
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.eclipse.californium.scandium.dtls.cipher.CipherSuite;
import org.thingsboard.common.util.SslUtil;
import java.security.cert.X509Certificate;
import java.util.List;
/**
* Builds a verify-only ({@code DTLS_ROLE = CLIENT_ONLY}) DTLS client connector for probing a
* CoAPS endpoint. Trust is scoped to the JVM's default trust store (cacerts) only a server
* whose certificate chains to a private/custom CA will fail the handshake. Hostname/wildcard
* verification is delegated to {@link WildcardAwareCertificateVerifier}.
*/
public class DtlsClientConnectorFactory {
// 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<SignatureAndHashAlgorithm> 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<CipherSuite> 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());
}
}

178
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<CertificateType> 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<X500Principal> 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<List<?>> 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;
}
}

19
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}'

68
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);
}
}

262
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));
}
}
Loading…
Cancel
Save