111 changed files with 6343 additions and 996 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,349 @@ |
|||
/** |
|||
* 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.server.coapserver; |
|||
|
|||
import org.bouncycastle.asn1.x500.X500Name; |
|||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; |
|||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; |
|||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; |
|||
import org.bouncycastle.util.io.pem.PemObject; |
|||
import org.bouncycastle.util.io.pem.PemWriter; |
|||
import org.eclipse.californium.core.CoapClient; |
|||
import org.eclipse.californium.core.CoapResource; |
|||
import org.eclipse.californium.core.CoapResponse; |
|||
import org.eclipse.californium.core.CoapServer; |
|||
import org.eclipse.californium.core.coap.CoAP; |
|||
import org.eclipse.californium.core.config.CoapConfig; |
|||
import org.eclipse.californium.core.network.CoapEndpoint; |
|||
import org.eclipse.californium.core.server.resources.CoapExchange; |
|||
import org.eclipse.californium.elements.config.Configuration; |
|||
import org.eclipse.californium.elements.util.SslContextUtil; |
|||
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.CertificateType; |
|||
import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider; |
|||
import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.io.TempDir; |
|||
import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType; |
|||
|
|||
import java.io.OutputStreamWriter; |
|||
import java.math.BigInteger; |
|||
import java.net.InetAddress; |
|||
import java.net.InetSocketAddress; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.KeyPair; |
|||
import java.security.KeyPairGenerator; |
|||
import java.security.cert.X509Certificate; |
|||
import java.util.Collections; |
|||
import java.util.Date; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static java.util.concurrent.TimeUnit.MILLISECONDS; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY; |
|||
|
|||
public class CoapDtlsCertificateReloadIntegrationTest { |
|||
|
|||
private static final String TEST_RESOURCE_PATH = "test"; |
|||
private static final String TEST_PAYLOAD = "hello-dtls"; |
|||
|
|||
@TempDir |
|||
Path tempDir; |
|||
|
|||
private CoapServer coapServer; |
|||
|
|||
@AfterEach |
|||
public void teardown() { |
|||
if (coapServer != null) { |
|||
coapServer.destroy(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); |
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
|
|||
Configuration config = createServerConfig(); |
|||
coapServer = new CoapServer(config); |
|||
coapServer.add(new TestResource()); |
|||
|
|||
int dtlsPort = findAvailablePort(); |
|||
CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointA); |
|||
coapServer.start(); |
|||
|
|||
CoapResponse responseA = doDtlsRequest(dtlsPort, certA); |
|||
assertThat(responseA).isNotNull(); |
|||
assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
coapServer.getEndpoints().remove(endpointA); |
|||
endpointA.stop(); |
|||
|
|||
CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointB); |
|||
endpointB.start(); |
|||
endpointA.destroy(); |
|||
|
|||
CoapResponse responseB = doDtlsRequest(dtlsPort, certB); |
|||
assertThat(responseB).isNotNull(); |
|||
assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD); |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); |
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
|
|||
Configuration config = createServerConfig(); |
|||
coapServer = new CoapServer(config); |
|||
coapServer.add(new TestResource()); |
|||
|
|||
int dtlsPort = findAvailablePort(); |
|||
CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointA); |
|||
coapServer.start(); |
|||
|
|||
CoapResponse responseA = doDtlsRequest(dtlsPort, certA); |
|||
assertThat(responseA).isNotNull(); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
coapServer.getEndpoints().remove(endpointA); |
|||
endpointA.stop(); |
|||
CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointB); |
|||
endpointB.start(); |
|||
endpointA.destroy(); |
|||
|
|||
CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA); |
|||
assertThat(failedResponse).isNull(); |
|||
|
|||
CoapResponse responseB = doDtlsRequest(dtlsPort, certB); |
|||
assertThat(responseB).isNotNull(); |
|||
assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception { |
|||
KeyPair keyPair = generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, cert); |
|||
writeKeyPem(keyFile, keyPair); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
|
|||
Configuration config = createServerConfig(); |
|||
coapServer = new CoapServer(config); |
|||
coapServer.add(new TestResource()); |
|||
|
|||
int dtlsPort = findAvailablePort(); |
|||
CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpoint1); |
|||
coapServer.start(); |
|||
|
|||
CoapResponse response1 = doDtlsRequest(dtlsPort, cert); |
|||
assertThat(response1).isNotNull(); |
|||
assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
|
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
coapServer.getEndpoints().remove(endpoint1); |
|||
endpoint1.stop(); |
|||
CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpoint2); |
|||
endpoint2.start(); |
|||
endpoint1.destroy(); |
|||
|
|||
CoapResponse response2 = doDtlsRequest(dtlsPort, cert); |
|||
assertThat(response2).isNotNull(); |
|||
assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
} |
|||
|
|||
private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) { |
|||
PemSslCredentials pem = new PemSslCredentials(); |
|||
pem.setCertFile(certFile.toAbsolutePath().toString()); |
|||
pem.setKeyFile(keyFile.toAbsolutePath().toString()); |
|||
|
|||
SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false); |
|||
config.setEnabled(true); |
|||
config.setType(SslCredentialsType.PEM); |
|||
config.setPem(pem); |
|||
config.setKeystore(new KeystoreSslCredentials()); |
|||
config.init(); |
|||
return config; |
|||
} |
|||
|
|||
private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) { |
|||
DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config); |
|||
dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port)); |
|||
dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY); |
|||
dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS); |
|||
dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE, |
|||
org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED); |
|||
|
|||
SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials( |
|||
credentials.getPrivateKey(), null, credentials.getCertificateChain()); |
|||
|
|||
dtlsBuilder.setCertificateIdentityProvider( |
|||
new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(), |
|||
Collections.singletonList(CertificateType.X_509))); |
|||
|
|||
dtlsBuilder.setAdvancedCertificateVerifier( |
|||
StaticNewAdvancedCertificateVerifier.builder() |
|||
.setTrustAllCertificates() |
|||
.build()); |
|||
|
|||
DTLSConnector connector = new DTLSConnector(dtlsBuilder.build()); |
|||
|
|||
CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder(); |
|||
endpointBuilder.setConfiguration(config); |
|||
endpointBuilder.setConnector(connector); |
|||
return endpointBuilder.build(); |
|||
} |
|||
|
|||
private KeyPair generateKeyPair() throws Exception { |
|||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); |
|||
kpg.initialize(256); |
|||
return kpg.generateKeyPair(); |
|||
} |
|||
|
|||
private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { |
|||
X500Name subject = new X500Name(subjectDn); |
|||
Date now = new Date(); |
|||
Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); |
|||
return new JcaX509CertificateConverter().getCertificate( |
|||
new JcaX509v3CertificateBuilder( |
|||
subject, BigInteger.valueOf(System.nanoTime()), now, expiry, |
|||
subject, kp.getPublic()) |
|||
.build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate()))); |
|||
} |
|||
|
|||
private void writeCertPem(Path path, X509Certificate cert) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private Configuration createServerConfig() { |
|||
Configuration config = new Configuration(); |
|||
config.set(CoapConfig.MAX_RETRANSMIT, 2); |
|||
config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); |
|||
return config; |
|||
} |
|||
|
|||
private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) { |
|||
try { |
|||
Configuration clientConfig = new Configuration(); |
|||
clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1); |
|||
clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); |
|||
clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS); |
|||
clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false); |
|||
clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false); |
|||
|
|||
DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig); |
|||
clientDtls.setAdvancedCertificateVerifier( |
|||
StaticNewAdvancedCertificateVerifier.builder() |
|||
.setTrustedCertificates(trustedCert) |
|||
.build()); |
|||
|
|||
DTLSConnector clientConnector = new DTLSConnector(clientDtls.build()); |
|||
CoapEndpoint clientEndpoint = new CoapEndpoint.Builder() |
|||
.setConfiguration(clientConfig) |
|||
.setConnector(clientConnector) |
|||
.build(); |
|||
|
|||
CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH); |
|||
client.setEndpoint(clientEndpoint); |
|||
client.setTimeout((long) 5000); |
|||
|
|||
try { |
|||
clientEndpoint.start(); |
|||
return client.get(); |
|||
} finally { |
|||
client.shutdown(); |
|||
clientEndpoint.destroy(); |
|||
} |
|||
} catch (Exception e) { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private int findAvailablePort() throws Exception { |
|||
try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) { |
|||
return socket.getLocalPort(); |
|||
} |
|||
} |
|||
|
|||
private static class TestResource extends CoapResource { |
|||
TestResource() { |
|||
super(TEST_RESOURCE_PATH); |
|||
} |
|||
|
|||
@Override |
|||
public void handleGET(CoapExchange exchange) { |
|||
exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD); |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,246 @@ |
|||
/** |
|||
* 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.server.coapserver; |
|||
|
|||
import org.eclipse.californium.core.CoapServer; |
|||
import org.eclipse.californium.core.network.CoapEndpoint; |
|||
import org.eclipse.californium.core.network.Endpoint; |
|||
import org.eclipse.californium.scandium.DTLSConnector; |
|||
import org.eclipse.californium.scandium.config.DtlsConnectorConfig; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.MockedConstruction; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.io.IOException; |
|||
import java.net.InetSocketAddress; |
|||
import java.util.List; |
|||
import java.util.concurrent.CopyOnWriteArrayList; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.doAnswer; |
|||
import static org.mockito.Mockito.doThrow; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.mockConstruction; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
public class CoapDtlsCertificateReloadTest { |
|||
|
|||
@Mock |
|||
private CoapServerContext mockCoapServerContext; |
|||
|
|||
@Mock |
|||
private TbCoapDtlsSettings mockDtlsSettings; |
|||
|
|||
@Mock |
|||
private CoapServer mockCoapServer; |
|||
|
|||
@Mock |
|||
private CoapEndpoint mockDtlsEndpoint; |
|||
|
|||
@Mock |
|||
private DTLSConnector mockDtlsConnector; |
|||
|
|||
private DefaultCoapServerService coapServerService; |
|||
|
|||
@BeforeEach |
|||
public void setup() { |
|||
coapServerService = new DefaultCoapServerService(); |
|||
ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext); |
|||
|
|||
when(mockCoapServerContext.getHost()).thenReturn("localhost"); |
|||
when(mockCoapServerContext.getPort()).thenReturn(5683); |
|||
doAnswer(invocation -> { |
|||
invocation.getArgument(0); |
|||
return null; |
|||
}).when(mockDtlsSettings).registerReloadCallback(any()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() { |
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
|||
|
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
|
|||
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
|||
assertThat(callbackCaptor.getValue()).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() { |
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); |
|||
|
|||
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
|||
|
|||
verify(mockDtlsSettings, never()).registerReloadCallback(any()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() { |
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
|||
|
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
|||
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
// dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw.
|
|||
// The old endpoint should not be stopped/destroyed when creation of the new one fails.
|
|||
reloadCallback.run(); |
|||
|
|||
verify(mockDtlsEndpoint, never()).stop(); |
|||
verify(mockDtlsConnector, never()).destroy(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() { |
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
|||
when(mockCoapServerContext.getHost()).thenReturn("localhost"); |
|||
when(mockCoapServerContext.getPort()).thenReturn(5683); |
|||
|
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
|||
|
|||
verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class)); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() { |
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
|||
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
assertThat(reloadCallback).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception { |
|||
// GIVEN
|
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
|||
|
|||
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); |
|||
TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); |
|||
when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); |
|||
when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); |
|||
when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); |
|||
|
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
|||
|
|||
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>(); |
|||
endpointsList.add(mockDtlsEndpoint); |
|||
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); |
|||
|
|||
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); |
|||
|
|||
try (MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class); |
|||
MockedConstruction<CoapEndpoint.Builder> builderMock = mockConstruction(CoapEndpoint.Builder.class, |
|||
(builder, context) -> { |
|||
when(builder.build()).thenReturn(mockNewEndpoint); |
|||
when(builder.setConfiguration(any())).thenReturn(builder); |
|||
when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); |
|||
})) { |
|||
|
|||
// WHEN
|
|||
ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint"); |
|||
|
|||
// THEN
|
|||
assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); |
|||
verify(mockDtlsEndpoint).stop(); |
|||
verify(mockDtlsEndpoint).destroy(); |
|||
verify(mockDtlsConnector).destroy(); |
|||
verify(mockCoapServer).addEndpoint(mockNewEndpoint); |
|||
verify(mockNewEndpoint).start(); |
|||
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception { |
|||
// GIVEN
|
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
|||
|
|||
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); |
|||
when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); |
|||
when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); |
|||
|
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
|||
|
|||
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>(); |
|||
endpointsList.add(mockDtlsEndpoint); |
|||
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); |
|||
|
|||
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); |
|||
doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); |
|||
|
|||
try (MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class); |
|||
MockedConstruction<CoapEndpoint.Builder> builderMock = mockConstruction(CoapEndpoint.Builder.class, |
|||
(builder, context) -> { |
|||
when(builder.build()).thenReturn(mockNewEndpoint); |
|||
when(builder.setConfiguration(any())).thenReturn(builder); |
|||
when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); |
|||
})) { |
|||
|
|||
// WHEN
|
|||
coapServerService.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
reloadCallback.run(); |
|||
|
|||
// THEN - new resources cleaned up
|
|||
DTLSConnector constructedConnector = dtlsMock.constructed().get(0); |
|||
verify(mockNewEndpoint).destroy(); |
|||
verify(constructedConnector).destroy(); |
|||
assertThat(endpointsList).doesNotContain(mockNewEndpoint); |
|||
// Old endpoint was stopped to release port, then restored after new one failed
|
|||
verify(mockDtlsEndpoint).stop(); |
|||
verify(mockDtlsEndpoint).start(); |
|||
// Old fields preserved
|
|||
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); |
|||
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
/** |
|||
* 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.server.common.data; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
class ResourceUtilsTest { |
|||
|
|||
@Test |
|||
public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() { |
|||
assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt")) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("Unable to find resource"); |
|||
} |
|||
|
|||
@Test |
|||
public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() { |
|||
String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class"); |
|||
|
|||
assertThat(result).isNotNull(); |
|||
assertThat(result).contains("ResourceUtilsTest"); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,160 @@ |
|||
/** |
|||
* 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.server.queue.notification; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.cache.Cache; |
|||
import org.springframework.cache.CacheManager; |
|||
import org.springframework.cache.concurrent.ConcurrentMapCacheManager; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.data.CacheConstants; |
|||
import org.thingsboard.server.common.data.notification.rule.NotificationRule; |
|||
import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; |
|||
import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; |
|||
|
|||
import java.util.List; |
|||
import java.util.concurrent.CopyOnWriteArrayList; |
|||
import java.util.concurrent.CyclicBarrier; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
class DefaultNotificationDeduplicationServiceTest { |
|||
|
|||
private static final int TIMEOUT = 30; |
|||
|
|||
private DefaultNotificationDeduplicationService deduplicationService; |
|||
private CacheManager cacheManager; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
deduplicationService = new DefaultNotificationDeduplicationService(); |
|||
deduplicationService.setDeduplicationDurations(""); |
|||
cacheManager = new ConcurrentMapCacheManager(CacheConstants.SENT_NOTIFICATIONS_CACHE); |
|||
ReflectionTestUtils.setField(deduplicationService, "cacheManager", cacheManager); |
|||
} |
|||
|
|||
@Test |
|||
void testFirstTriggerIsNotDeduplicated() { |
|||
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); |
|||
NotificationRule rule = mockRule(); |
|||
|
|||
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void testSecondTriggerIsDeduplicated() { |
|||
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); |
|||
NotificationRule rule = mockRule(); |
|||
|
|||
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); |
|||
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void testTriggerPassesAfterDeduplicationWindowExpires() { |
|||
NotificationRuleTrigger trigger = mockTrigger(50); // 50ms dedup window
|
|||
NotificationRule rule = mockRule(); |
|||
|
|||
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); |
|||
|
|||
try { |
|||
Thread.sleep(200); // wait well past the 50ms window
|
|||
} catch (InterruptedException ignored) {} |
|||
|
|||
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void testFutureTimestampFromExternalCacheIsDiscarded() { |
|||
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); |
|||
NotificationRule rule = mockRule(); |
|||
String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); |
|||
|
|||
// Put a timestamp 2 hours in the future into external cache
|
|||
Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); |
|||
externalCache.put(dedupKey, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2)); |
|||
|
|||
// Should NOT be deduplicated — future timestamp must be discarded
|
|||
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void testValidTimestampFromExternalCacheIsDeduplicated() { |
|||
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); |
|||
NotificationRule rule = mockRule(); |
|||
String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); |
|||
|
|||
// Put a recent timestamp into external cache
|
|||
Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); |
|||
externalCache.put(dedupKey, System.currentTimeMillis()); |
|||
|
|||
// Should be deduplicated — valid external cache entry
|
|||
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void testConcurrentTriggersProduceExactlyOneNonDeduplicated() throws Exception { |
|||
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); |
|||
NotificationRule rule = mockRule(); |
|||
|
|||
int threadCount = 10; |
|||
CyclicBarrier barrier = new CyclicBarrier(threadCount); |
|||
List<Boolean> results = new CopyOnWriteArrayList<>(); |
|||
|
|||
ExecutorService executor = Executors.newFixedThreadPool(threadCount); |
|||
try { |
|||
for (int i = 0; i < threadCount; i++) { |
|||
executor.submit(() -> { |
|||
try { |
|||
barrier.await(TIMEOUT, TimeUnit.SECONDS); |
|||
} catch (Exception ignored) {} |
|||
results.add(deduplicationService.alreadyProcessed(trigger, rule)); |
|||
}); |
|||
} |
|||
executor.shutdown(); |
|||
assertThat(executor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)).isTrue(); |
|||
|
|||
assertThat(results).hasSize(threadCount); |
|||
assertThat(results.stream().filter(r -> !r).count()) |
|||
.as("exactly one trigger should pass through deduplication") |
|||
.isEqualTo(1); |
|||
} finally { |
|||
executor.shutdownNow(); |
|||
} |
|||
} |
|||
|
|||
private NotificationRuleTrigger mockTrigger(long deduplicationDurationMs) { |
|||
NotificationRuleTrigger trigger = mock(NotificationRuleTrigger.class); |
|||
when(trigger.getType()).thenReturn(NotificationRuleTriggerType.RESOURCES_SHORTAGE); |
|||
when(trigger.getDeduplicationKey()).thenReturn("test:dedup:key"); |
|||
when(trigger.getDefaultDeduplicationDuration()).thenReturn(deduplicationDurationMs); |
|||
when(trigger.getDeduplicationStrategy()).thenReturn(NotificationRuleTrigger.DeduplicationStrategy.ONLY_MATCHING); |
|||
return trigger; |
|||
} |
|||
|
|||
private NotificationRule mockRule() { |
|||
NotificationRule rule = mock(NotificationRule.class); |
|||
when(rule.getDeduplicationKey()).thenReturn("rule:key"); |
|||
return rule; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,198 @@ |
|||
/** |
|||
* 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.server.transport.lwm2m.bootstrap; |
|||
|
|||
import org.eclipse.leshan.server.bootstrap.LeshanBootstrapServer; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.Mockito; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.transport.TransportService; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
|||
import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; |
|||
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; |
|||
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; |
|||
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; |
|||
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.doReturn; |
|||
import static org.mockito.Mockito.doThrow; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
public class LwM2mBootstrapCertificateReloadTest { |
|||
|
|||
@Mock |
|||
private LwM2MTransportServerConfig mockServerConfig; |
|||
|
|||
@Mock |
|||
private LwM2MTransportBootstrapConfig mockBootstrapConfig; |
|||
|
|||
@Mock |
|||
private LwM2MBootstrapSecurityStore mockSecurityStore; |
|||
|
|||
@Mock |
|||
private LwM2MInMemoryBootstrapConfigStore mockConfigStore; |
|||
|
|||
@Mock |
|||
private TransportService mockTransportService; |
|||
|
|||
@Mock |
|||
private TbLwM2MDtlsBootstrapCertificateVerifier mockCertificateVerifier; |
|||
|
|||
@Mock |
|||
private LeshanBootstrapServer mockBootstrapServer; |
|||
|
|||
@Mock |
|||
private SslCredentials mockSslCredentials; |
|||
|
|||
private LwM2MTransportBootstrapService bootstrapService; |
|||
|
|||
@BeforeEach |
|||
public void setup() { |
|||
bootstrapService = new LwM2MTransportBootstrapService( |
|||
mockServerConfig, |
|||
mockBootstrapConfig, |
|||
mockSecurityStore, |
|||
mockConfigStore, |
|||
mockTransportService, |
|||
mockCertificateVerifier |
|||
); |
|||
|
|||
when(mockBootstrapConfig.getHost()).thenReturn("localhost"); |
|||
when(mockBootstrapConfig.getPort()).thenReturn(5687); |
|||
when(mockBootstrapConfig.getSecureHost()).thenReturn("localhost"); |
|||
when(mockBootstrapConfig.getSecurePort()).thenReturn(5688); |
|||
when(mockBootstrapConfig.getSslCredentials()).thenReturn(mockSslCredentials); |
|||
when(mockServerConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); |
|||
} |
|||
|
|||
@Test |
|||
public void givenInit_whenCalled_thenShouldRegisterCertificateReloadCallback() { |
|||
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); |
|||
|
|||
bootstrapService.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
assertThat(callbackCaptor.getValue()).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { |
|||
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); |
|||
|
|||
// Force getLhBootstrapServer() to fail by returning null host (causes InetSocketAddress to throw)
|
|||
when(mockBootstrapConfig.getHost()).thenReturn(null); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
bootstrapService.afterSingletonsInstantiated(); |
|||
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
// getLhBootstrapServer() will fail due to null host before old server is stopped.
|
|||
// The old server should NOT be destroyed since the new server was never created.
|
|||
reloadCallback.run(); |
|||
|
|||
verify(mockBootstrapServer, never()).stop(); |
|||
verify(mockBootstrapServer, never()).destroy(); |
|||
assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); |
|||
} |
|||
|
|||
@Test |
|||
public void givenNullServer_whenRecreate_thenShouldNotThrow() { |
|||
ReflectionTestUtils.setField(bootstrapService, "server", null); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
bootstrapService.afterSingletonsInstantiated(); |
|||
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
// Should not throw — callback catches exceptions internally
|
|||
reloadCallback.run(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCertificateUpdate_whenRecreate_thenShouldUseNewCredentials() { |
|||
SslCredentials oldCredentials = mockSslCredentials; |
|||
SslCredentials newCredentials = mock(SslCredentials.class); |
|||
|
|||
when(mockBootstrapConfig.getSslCredentials()).thenReturn(oldCredentials).thenReturn(newCredentials); |
|||
|
|||
SslCredentials firstCall = mockBootstrapConfig.getSslCredentials(); |
|||
assertThat(firstCall).isEqualTo(oldCredentials); |
|||
|
|||
SslCredentials secondCall = mockBootstrapConfig.getSslCredentials(); |
|||
assertThat(secondCall).isEqualTo(newCredentials); |
|||
|
|||
verify(mockBootstrapConfig, times(2)).getSslCredentials(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenRegistered_thenShouldRegisterExactlyOne() { |
|||
bootstrapService.afterSingletonsInstantiated(); |
|||
|
|||
verify(mockBootstrapConfig, times(1)).registerServerReloadCallback(any()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenNewServerStartFails_thenOldServerRestarted() { |
|||
// GIVEN
|
|||
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); |
|||
|
|||
LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); |
|||
doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); |
|||
|
|||
LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); |
|||
doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
spyService.afterSingletonsInstantiated(); |
|||
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
// WHEN
|
|||
reloadCallback.run(); |
|||
|
|||
// THEN
|
|||
// Old server is stopped (not destroyed) to release ports
|
|||
verify(mockBootstrapServer).stop(); |
|||
verify(mockBootstrapServer, never()).destroy(); |
|||
// The new server fails to start and is destroyed
|
|||
verify(mockNewServer).destroy(); |
|||
// Old server is restarted (not rebuilt from potentially stale credentials)
|
|||
verify(mockBootstrapServer).start(); |
|||
assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
/** |
|||
* 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.server.transport.lwm2m.config; |
|||
|
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
|
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static java.util.concurrent.TimeUnit.SECONDS; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.awaitility.Awaitility.await; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class LwM2MTransportServerConfigDebounceTest { |
|||
|
|||
private static final long DEBOUNCE_SECONDS = (long) ReflectionTestUtils.getField(LwM2MTransportServerConfig.class, "RELOAD_DEBOUNCE_SECONDS"); |
|||
|
|||
@Mock |
|||
private SslCredentialsConfig credentialsConfig; |
|||
|
|||
@Mock |
|||
private SslCredentialsConfig trustCredentialsConfig; |
|||
|
|||
private LwM2MTransportServerConfig config; |
|||
|
|||
@BeforeEach |
|||
public void setup() { |
|||
config = new LwM2MTransportServerConfig(); |
|||
ReflectionTestUtils.setField(config, "credentialsConfig", credentialsConfig); |
|||
ReflectionTestUtils.setField(config, "trustCredentialsConfig", trustCredentialsConfig); |
|||
} |
|||
|
|||
@AfterEach |
|||
public void teardown() { |
|||
config.destroy(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSingleTrigger_whenScheduleServerReload_thenCallbackFiresOnce() { |
|||
AtomicInteger callCount = new AtomicInteger(0); |
|||
config.registerServerReloadCallback(callCount::incrementAndGet); |
|||
|
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
} |
|||
|
|||
@Test |
|||
public void givenTwoRapidTriggers_whenScheduleServerReload_thenCallbackFiresOnce() { |
|||
AtomicInteger callCount = new AtomicInteger(0); |
|||
config.registerServerReloadCallback(callCount::incrementAndGet); |
|||
|
|||
invokeScheduleServerReload(); |
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
|
|||
// Wait extra to confirm no second invocation
|
|||
await().during(DEBOUNCE_SECONDS + 1, SECONDS) |
|||
.atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
} |
|||
|
|||
@Test |
|||
public void givenTriggersOutsideDebounceWindow_whenScheduleServerReload_thenCallbackFiresTwice() { |
|||
AtomicInteger callCount = new AtomicInteger(0); |
|||
config.registerServerReloadCallback(callCount::incrementAndGet); |
|||
|
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
|
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(2)); |
|||
} |
|||
|
|||
private void invokeScheduleServerReload() { |
|||
ReflectionTestUtils.invokeMethod(config, "scheduleServerReload"); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,192 @@ |
|||
/** |
|||
* 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.server.transport.lwm2m.server; |
|||
|
|||
import org.eclipse.leshan.server.LeshanServer; |
|||
import org.eclipse.leshan.server.observation.ObservationService; |
|||
import org.eclipse.leshan.server.registration.RegistrationService; |
|||
import org.eclipse.leshan.server.registration.RegistrationStore; |
|||
import org.eclipse.leshan.server.send.SendService; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.cache.ota.OtaPackageDataCache; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
|||
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; |
|||
import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MAuthorizer; |
|||
import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MDtlsCertificateVerifier; |
|||
import org.thingsboard.server.transport.lwm2m.server.store.TbSecurityStore; |
|||
import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
public class LwM2mServerCertificateReloadTest { |
|||
|
|||
@Mock |
|||
private LwM2mTransportContext mockContext; |
|||
|
|||
@Mock |
|||
private LwM2MTransportServerConfig mockConfig; |
|||
|
|||
@Mock |
|||
private OtaPackageDataCache mockOtaCache; |
|||
|
|||
@Mock |
|||
private LwM2mUplinkMsgHandler mockHandler; |
|||
|
|||
@Mock |
|||
private RegistrationStore mockRegistrationStore; |
|||
|
|||
@Mock |
|||
private TbSecurityStore mockSecurityStore; |
|||
|
|||
@Mock |
|||
private TbLwM2MDtlsCertificateVerifier mockCertificateVerifier; |
|||
|
|||
@Mock |
|||
private TbLwM2MAuthorizer mockAuthorizer; |
|||
|
|||
@Mock |
|||
private LwM2mVersionedModelProvider mockModelProvider; |
|||
|
|||
@Mock |
|||
private LeshanServer mockLeshanServer; |
|||
|
|||
@Mock |
|||
private RegistrationService mockRegistrationService; |
|||
|
|||
@Mock |
|||
private ObservationService mockObservationService; |
|||
|
|||
@Mock |
|||
private SendService mockSendService; |
|||
|
|||
@Mock |
|||
private SslCredentials mockSslCredentials; |
|||
|
|||
private DefaultLwM2mTransportService lwm2mTransportService; |
|||
|
|||
@BeforeEach |
|||
public void setup() { |
|||
lwm2mTransportService = new DefaultLwM2mTransportService( |
|||
mockContext, |
|||
mockConfig, |
|||
mockOtaCache, |
|||
mockHandler, |
|||
mockRegistrationStore, |
|||
mockSecurityStore, |
|||
mockCertificateVerifier, |
|||
mockAuthorizer, |
|||
mockModelProvider |
|||
); |
|||
|
|||
when(mockConfig.getHost()).thenReturn("localhost"); |
|||
when(mockConfig.getPort()).thenReturn(5683); |
|||
when(mockConfig.getSecureHost()).thenReturn("localhost"); |
|||
when(mockConfig.getSecurePort()).thenReturn(5684); |
|||
when(mockConfig.getSslCredentials()).thenReturn(mockSslCredentials); |
|||
|
|||
when(mockLeshanServer.getRegistrationService()).thenReturn(mockRegistrationService); |
|||
when(mockLeshanServer.getObservationService()).thenReturn(mockObservationService); |
|||
when(mockLeshanServer.getSendService()).thenReturn(mockSendService); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRegisterCertificateReloadCallback_whenInvoked_thenShouldRegisterCallback() { |
|||
lwm2mTransportService.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
assertThat(callbackCaptor.getValue()).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { |
|||
lwm2mTransportService.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); |
|||
|
|||
// Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw)
|
|||
when(mockConfig.getHost()).thenReturn(null); |
|||
|
|||
// With create-then-swap, the old server should NOT be stopped/destroyed if the new one fails to build.
|
|||
reloadCallback.run(); |
|||
|
|||
verify(mockLeshanServer, never()).stop(); |
|||
verify(mockLeshanServer, never()).destroy(); |
|||
// Old server should still be the active one
|
|||
assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); |
|||
} |
|||
|
|||
@Test |
|||
public void givenServerWithListeners_whenNewServerCreationFails_thenListenersArePreserved() { |
|||
lwm2mTransportService.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); |
|||
|
|||
LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); |
|||
ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); |
|||
|
|||
// Force getLhServer() to fail by returning null host
|
|||
when(mockConfig.getHost()).thenReturn(null); |
|||
|
|||
// Invoke the callback — new server creation will fail, old listeners should stay
|
|||
callbackCaptor.getValue().run(); |
|||
|
|||
verify(mockRegistrationService, never()).removeListener(any()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMultipleReloadCallbacks_whenInvoked_thenShouldRegisterExactlyOne() { |
|||
lwm2mTransportService.afterSingletonsInstantiated(); |
|||
|
|||
verify(mockConfig, times(1)).registerServerReloadCallback(any()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCertificateReload_whenServerNull_thenShouldNotThrow() { |
|||
lwm2mTransportService.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
ReflectionTestUtils.setField(lwm2mTransportService, "server", null); |
|||
|
|||
// Should not throw - callback catches exceptions internally
|
|||
callbackCaptor.getValue().run(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,276 @@ |
|||
/** |
|||
* 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.server.transport.mqtt; |
|||
|
|||
import org.bouncycastle.asn1.x500.X500Name; |
|||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; |
|||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; |
|||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; |
|||
import org.bouncycastle.util.io.pem.PemObject; |
|||
import org.bouncycastle.util.io.pem.PemWriter; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.api.io.TempDir; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.transport.TransportService; |
|||
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
|
|||
import javax.net.ssl.SSLContext; |
|||
import javax.net.ssl.SSLServerSocket; |
|||
import javax.net.ssl.SSLSocket; |
|||
import javax.net.ssl.TrustManager; |
|||
import javax.net.ssl.X509TrustManager; |
|||
import java.io.OutputStreamWriter; |
|||
import java.math.BigInteger; |
|||
import java.net.InetAddress; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.KeyPair; |
|||
import java.security.KeyPairGenerator; |
|||
import java.security.cert.Certificate; |
|||
import java.security.cert.X509Certificate; |
|||
import java.util.Date; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class MqttSslCertificateReloadIntegrationTest { |
|||
|
|||
@TempDir |
|||
Path tempDir; |
|||
|
|||
@Mock |
|||
private TransportService transportService; |
|||
|
|||
@Test |
|||
public void givenMqttSslProvider_whenCertFileChangedAndReloadTriggered_thenNewConnectionSeesNewCert() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); |
|||
|
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); |
|||
|
|||
SSLContext ctxA = getProviderSslContext(provider); |
|||
X509Certificate servedA; |
|||
try (SSLServerSocket ss = createServerSocket(ctxA)) { |
|||
servedA = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(servedA.getSubjectX500Principal()).isEqualTo(certA.getSubjectX500Principal()); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
|
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
SSLContext ctxB = getProviderSslContext(provider); |
|||
assertThat(ctxB).isNotSameAs(ctxA); |
|||
X509Certificate servedB; |
|||
try (SSLServerSocket ss = createServerSocket(ctxB)) { |
|||
servedB = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(servedB.getSubjectX500Principal()).isEqualTo(certB.getSubjectX500Principal()); |
|||
assertThat(servedB.getSubjectX500Principal()).isNotEqualTo(servedA.getSubjectX500Principal()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMqttSslProvider_whenReloadCalledWithSameFiles_thenSslContextIsRecreated() throws Exception { |
|||
KeyPair keyPair = generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(keyPair, "CN=SameCert"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, cert); |
|||
writeKeyPem(keyFile, keyPair); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); |
|||
|
|||
SSLContext ctx1 = getProviderSslContext(provider); |
|||
assertThat(ctx1).isNotNull(); |
|||
|
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
SSLContext ctx2 = getProviderSslContext(provider); |
|||
assertThat(ctx2).isNotSameAs(ctx1); |
|||
|
|||
X509Certificate served; |
|||
try (SSLServerSocket ss = createServerSocket(ctx2)) { |
|||
served = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(served.getSubjectX500Principal()).isEqualTo(cert.getSubjectX500Principal()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMqttSslProvider_whenMultipleReloads_thenEachProducesNewContext() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); |
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); |
|||
KeyPair keyPairC = generateKeyPair(); |
|||
X509Certificate certC = generateSelfSignedCert(keyPairC, "CN=CertC"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); |
|||
|
|||
SSLContext ctx1 = getProviderSslContext(provider); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
SSLContext ctx2 = getProviderSslContext(provider); |
|||
|
|||
writeCertPem(certFile, certC); |
|||
writeKeyPem(keyFile, keyPairC); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
SSLContext ctx3 = getProviderSslContext(provider); |
|||
|
|||
assertThat(ctx1).isNotSameAs(ctx2); |
|||
assertThat(ctx2).isNotSameAs(ctx3); |
|||
|
|||
X509Certificate served; |
|||
try (SSLServerSocket ss = createServerSocket(ctx3)) { |
|||
served = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(served.getSubjectX500Principal()).isEqualTo(certC.getSubjectX500Principal()); |
|||
} |
|||
|
|||
private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) throws Exception { |
|||
PemSslCredentials pem = new PemSslCredentials(); |
|||
pem.setCertFile(certFile.toAbsolutePath().toString()); |
|||
pem.setKeyFile(keyFile.toAbsolutePath().toString()); |
|||
|
|||
SslCredentialsConfig config = new SslCredentialsConfig("MQTT SSL Test", false); |
|||
config.setEnabled(true); |
|||
config.setType(org.thingsboard.server.common.transport.config.ssl.SslCredentialsType.PEM); |
|||
config.setPem(pem); |
|||
config.setKeystore(new org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials()); |
|||
config.init(); |
|||
return config; |
|||
} |
|||
|
|||
private MqttSslHandlerProvider createMqttSslHandlerProvider(SslCredentialsConfig credentialsConfig) { |
|||
MqttSslHandlerProvider provider = new MqttSslHandlerProvider(); |
|||
ReflectionTestUtils.setField(provider, "sslProtocol", "TLSv1.2"); |
|||
ReflectionTestUtils.setField(provider, "mqttSslCredentialsConfig", credentialsConfig); |
|||
ReflectionTestUtils.setField(provider, "transportService", transportService); |
|||
provider.afterSingletonsInstantiated(); |
|||
return provider; |
|||
} |
|||
|
|||
/** |
|||
* Triggers SSLContext creation through the provider's getSslHandler() path, |
|||
* then extracts the cached SSLContext for direct server socket use. |
|||
*/ |
|||
private SSLContext getProviderSslContext(MqttSslHandlerProvider provider) { |
|||
provider.getSslHandler(); |
|||
return (SSLContext) ReflectionTestUtils.getField(provider, "sslContext"); |
|||
} |
|||
|
|||
private KeyPair generateKeyPair() throws Exception { |
|||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); |
|||
kpg.initialize(2048); |
|||
return kpg.generateKeyPair(); |
|||
} |
|||
|
|||
private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { |
|||
X500Name subject = new X500Name(subjectDn); |
|||
Date now = new Date(); |
|||
Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); |
|||
return new JcaX509CertificateConverter().getCertificate( |
|||
new JcaX509v3CertificateBuilder( |
|||
subject, BigInteger.valueOf(System.nanoTime()), now, expiry, |
|||
subject, kp.getPublic()) |
|||
.build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); |
|||
} |
|||
|
|||
private void writeCertPem(Path path, X509Certificate cert) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private SSLServerSocket createServerSocket(SSLContext ctx) throws Exception { |
|||
return (SSLServerSocket) ctx.getServerSocketFactory().createServerSocket(0, 1, InetAddress.getLoopbackAddress()); |
|||
} |
|||
|
|||
private X509Certificate doHandshakeAndGetServerCert(SSLServerSocket serverSocket) throws Exception { |
|||
Thread acceptor = new Thread(() -> { |
|||
try (var conn = serverSocket.accept()) { |
|||
conn.getInputStream().read(); |
|||
} catch (Exception ignored) {} |
|||
}); |
|||
acceptor.setDaemon(true); |
|||
acceptor.start(); |
|||
|
|||
SSLContext clientCtx = SSLContext.getInstance("TLSv1.2"); |
|||
clientCtx.init(null, new TrustManager[]{new TrustAllManager()}, null); |
|||
|
|||
try (SSLSocket client = (SSLSocket) clientCtx.getSocketFactory() |
|||
.createSocket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) { |
|||
client.setSoTimeout(5000); |
|||
client.startHandshake(); |
|||
|
|||
Certificate[] peerCerts = client.getSession().getPeerCertificates(); |
|||
assertThat(peerCerts).isNotEmpty(); |
|||
return (X509Certificate) peerCerts[0]; |
|||
} finally { |
|||
acceptor.join(5000); |
|||
} |
|||
} |
|||
|
|||
private static class TrustAllManager implements X509TrustManager { |
|||
|
|||
@Override |
|||
public void checkClientTrusted(X509Certificate[] chain, String authType) { |
|||
|
|||
} |
|||
|
|||
@Override |
|||
public void checkServerTrusted(X509Certificate[] chain, String authType) { |
|||
|
|||
} |
|||
|
|||
@Override |
|||
public X509Certificate[] getAcceptedIssuers() { |
|||
return new X509Certificate[0]; |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,197 @@ |
|||
/** |
|||
* 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.server.transport.mqtt; |
|||
|
|||
import io.netty.handler.ssl.SslHandler; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.transport.TransportService; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
|
|||
import javax.net.ssl.KeyManager; |
|||
import javax.net.ssl.KeyManagerFactory; |
|||
import javax.net.ssl.SSLContext; |
|||
import javax.net.ssl.TrustManager; |
|||
import javax.net.ssl.TrustManagerFactory; |
|||
import javax.net.ssl.X509TrustManager; |
|||
import java.util.List; |
|||
import java.util.concurrent.CopyOnWriteArrayList; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class MqttSslHandlerProviderTest { |
|||
|
|||
@Mock |
|||
private SslCredentialsConfig mockCredentialsConfig; |
|||
|
|||
@Mock |
|||
private SslCredentials mockCredentials; |
|||
|
|||
@Mock |
|||
private TransportService mockTransportService; |
|||
|
|||
private MqttSslHandlerProvider sslHandlerProvider; |
|||
|
|||
@BeforeEach |
|||
public void setup() throws Exception { |
|||
sslHandlerProvider = new MqttSslHandlerProvider(); |
|||
ReflectionTestUtils.setField(sslHandlerProvider, "mqttSslCredentialsConfig", mockCredentialsConfig); |
|||
ReflectionTestUtils.setField(sslHandlerProvider, "transportService", mockTransportService); |
|||
ReflectionTestUtils.setField(sslHandlerProvider, "sslProtocol", "TLSv1.2"); |
|||
|
|||
KeyManagerFactory mockKmf = mock(KeyManagerFactory.class); |
|||
TrustManagerFactory mockTmf = mock(TrustManagerFactory.class); |
|||
X509TrustManager mockTrustManager = mock(X509TrustManager.class); |
|||
|
|||
when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); |
|||
when(mockCredentials.createKeyManagerFactory()).thenReturn(mockKmf); |
|||
when(mockCredentials.createTrustManagerFactory()).thenReturn(mockTmf); |
|||
when(mockKmf.getKeyManagers()).thenReturn(new KeyManager[0]); |
|||
when(mockTmf.getTrustManagers()).thenReturn(new TrustManager[]{mockTrustManager}); |
|||
} |
|||
|
|||
@Test |
|||
public void givenInitialized_whenGetSslHandler_thenShouldCreateSSLContext() { |
|||
sslHandlerProvider.afterSingletonsInstantiated(); |
|||
|
|||
SslHandler handler1 = sslHandlerProvider.getSslHandler(); |
|||
SslHandler handler2 = sslHandlerProvider.getSslHandler(); |
|||
|
|||
assertThat(handler1).isNotNull(); |
|||
assertThat(handler2).isNotNull(); |
|||
assertThat(handler1).isNotSameAs(handler2); |
|||
|
|||
SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(context).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCertificatesReloaded_whenReloadCallbackInvoked_thenShouldRebuildSSLContextEagerly() { |
|||
sslHandlerProvider.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(initialContext).isNotNull(); |
|||
|
|||
reloadCallback.run(); |
|||
|
|||
// After reload the context is rebuilt eagerly (no null-invalidation), so handshakes stay lock-free.
|
|||
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(contextAfterReload).isNotNull(); |
|||
assertThat(contextAfterReload).isNotSameAs(initialContext); |
|||
|
|||
SslHandler handler = sslHandlerProvider.getSslHandler(); |
|||
assertThat(handler).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenConcurrentGetSslHandlerCalls_whenContextAlreadyBuilt_thenAllReadsReturnSameContext() throws Exception { |
|||
sslHandlerProvider.afterSingletonsInstantiated(); |
|||
|
|||
SSLContext contextBefore = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(contextBefore).isNotNull(); |
|||
|
|||
CountDownLatch startLatch = new CountDownLatch(1); |
|||
CountDownLatch doneLatch = new CountDownLatch(5); |
|||
List<SslHandler> handlers = new CopyOnWriteArrayList<>(); |
|||
|
|||
for (int i = 0; i < 5; i++) { |
|||
new Thread(() -> { |
|||
try { |
|||
startLatch.await(); |
|||
handlers.add(sslHandlerProvider.getSslHandler()); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException(e); |
|||
} finally { |
|||
doneLatch.countDown(); |
|||
} |
|||
}).start(); |
|||
} |
|||
|
|||
startLatch.countDown(); |
|||
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
|||
|
|||
assertThat(completed).isTrue(); |
|||
assertThat(handlers).hasSize(5).allSatisfy(h -> assertThat(h).isNotNull()); |
|||
// Concurrent handshakes read the same pre-built context without the old sync bottleneck.
|
|||
SSLContext contextAfter = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(contextAfter).isSameAs(contextBefore); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenInvoked_thenShouldSwapSSLContextEagerly() { |
|||
sslHandlerProvider.afterSingletonsInstantiated(); |
|||
|
|||
sslHandlerProvider.getSslHandler(); |
|||
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(initialContext).isNotNull(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
callbackCaptor.getValue().run(); |
|||
|
|||
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(contextAfterReload).isNotNull(); |
|||
assertThat(contextAfterReload).isNotSameAs(initialContext); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMultipleReloads_whenGetSslHandler_thenShouldRecreateEachTime() { |
|||
sslHandlerProvider.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
SSLContext context1; |
|||
SSLContext context2; |
|||
SSLContext context3; |
|||
|
|||
sslHandlerProvider.getSslHandler(); |
|||
context1 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(context1).isNotNull(); |
|||
|
|||
reloadCallback.run(); |
|||
sslHandlerProvider.getSslHandler(); |
|||
context2 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(context2).isNotNull(); |
|||
assertThat(context2).isNotSameAs(context1); |
|||
|
|||
reloadCallback.run(); |
|||
sslHandlerProvider.getSslHandler(); |
|||
context3 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(context3).isNotNull(); |
|||
assertThat(context3).isNotSameAs(context2); |
|||
assertThat(context3).isNotSameAs(context1); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,111 @@ |
|||
/** |
|||
* 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.server.transport.mqtt; |
|||
|
|||
import io.netty.channel.Channel; |
|||
import io.netty.channel.EventLoopGroup; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.net.BindException; |
|||
import java.net.InetAddress; |
|||
import java.net.ServerSocket; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.awaitility.Awaitility.await; |
|||
import static org.mockito.Mockito.mock; |
|||
|
|||
public class MqttTransportServiceTest { |
|||
|
|||
private static final String HOST = "127.0.0.1"; |
|||
|
|||
private MqttTransportService service; |
|||
private ServerSocket occupiedSocket; |
|||
private int occupiedPort; |
|||
|
|||
@BeforeEach |
|||
public void setUp() throws Exception { |
|||
occupiedSocket = new ServerSocket(0, 50, InetAddress.getByName(HOST)); |
|||
occupiedPort = occupiedSocket.getLocalPort(); |
|||
|
|||
service = new MqttTransportService(); |
|||
ReflectionTestUtils.setField(service, "host", HOST); |
|||
ReflectionTestUtils.setField(service, "port", occupiedPort); |
|||
ReflectionTestUtils.setField(service, "sslEnabled", false); |
|||
ReflectionTestUtils.setField(service, "sslHost", HOST); |
|||
ReflectionTestUtils.setField(service, "sslPort", 0); |
|||
ReflectionTestUtils.setField(service, "leakDetectorLevel", "DISABLED"); |
|||
ReflectionTestUtils.setField(service, "bossGroupThreadCount", 1); |
|||
ReflectionTestUtils.setField(service, "workerGroupThreadCount", 1); |
|||
ReflectionTestUtils.setField(service, "keepAlive", true); |
|||
ReflectionTestUtils.setField(service, "context", mock(MqttTransportContext.class)); |
|||
} |
|||
|
|||
@AfterEach |
|||
public void tearDown() throws Exception { |
|||
if (occupiedSocket != null && !occupiedSocket.isClosed()) { |
|||
occupiedSocket.close(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
public void whenPlainBindFails_thenInitThrowsAndReleasesNettyResources() { |
|||
assertThatThrownBy(() -> service.init()) |
|||
.isInstanceOf(BindException.class); |
|||
|
|||
EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); |
|||
EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); |
|||
|
|||
assertThat(boss).isNotNull(); |
|||
assertThat(worker).isNotNull(); |
|||
assertThat(boss.isShuttingDown()).isTrue(); |
|||
assertThat(worker.isShuttingDown()).isTrue(); |
|||
|
|||
await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); |
|||
await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); |
|||
} |
|||
|
|||
@Test |
|||
public void whenSslBindFailsAfterPlainBound_thenInitThrowsAndClosesPlainChannelAndReleasesNettyResources() { |
|||
ReflectionTestUtils.setField(service, "port", 0); |
|||
ReflectionTestUtils.setField(service, "sslEnabled", true); |
|||
ReflectionTestUtils.setField(service, "sslPort", occupiedPort); |
|||
|
|||
assertThatThrownBy(() -> service.init()) |
|||
.isInstanceOf(BindException.class); |
|||
|
|||
Channel serverChannel = (Channel) ReflectionTestUtils.getField(service, "serverChannel"); |
|||
Channel sslServerChannel = (Channel) ReflectionTestUtils.getField(service, "sslServerChannel"); |
|||
EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); |
|||
EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); |
|||
|
|||
assertThat(serverChannel).isNotNull(); |
|||
assertThat(sslServerChannel).isNull(); |
|||
assertThat(boss).isNotNull(); |
|||
assertThat(worker).isNotNull(); |
|||
|
|||
await().atMost(10, TimeUnit.SECONDS).until(() -> !serverChannel.isOpen()); |
|||
|
|||
assertThat(boss.isShuttingDown()).isTrue(); |
|||
assertThat(worker.isShuttingDown()).isTrue(); |
|||
await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); |
|||
await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); |
|||
} |
|||
} |
|||
@ -0,0 +1,299 @@ |
|||
/** |
|||
* 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.server.common.transport.service; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.DisposableBean; |
|||
import org.springframework.beans.factory.SmartInitializingSingleton; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.context.ApplicationContext; |
|||
import org.springframework.stereotype.Component; |
|||
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
import org.thingsboard.server.queue.util.TbTransportComponent; |
|||
|
|||
import java.io.IOException; |
|||
import java.io.InputStream; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.MessageDigest; |
|||
import java.util.ArrayList; |
|||
import java.util.Base64; |
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.ScheduledExecutorService; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
@Slf4j |
|||
@Component |
|||
@TbTransportComponent |
|||
public class CertificateReloadManager implements SmartInitializingSingleton, DisposableBean { |
|||
|
|||
private static final int MAX_CONSECUTIVE_FAILURES = 10; |
|||
|
|||
@Value("${transport.ssl.certificate.reload.enabled:true}") |
|||
private boolean reloadEnabled; |
|||
|
|||
@Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") |
|||
private long checkIntervalInSeconds; |
|||
|
|||
@Autowired |
|||
protected ApplicationContext applicationContext; |
|||
|
|||
private final Map<String, CertificateWatcher> watchers = new ConcurrentHashMap<>(); |
|||
private volatile ScheduledExecutorService scheduler; |
|||
|
|||
public void registerWatcher(String name, Path certPath, Runnable reloadCallback) { |
|||
registerWatcher(name, List.of(certPath), reloadCallback); |
|||
} |
|||
|
|||
public void registerWatcher(String name, List<Path> certPaths, Runnable reloadCallback) { |
|||
watchers.put(name, new CertificateWatcher(certPaths, reloadCallback)); |
|||
log.info("Registered certificate watcher for: {} (watching {} file(s))", name, certPaths.size()); |
|||
} |
|||
|
|||
private void checkCertificates() { |
|||
watchers.forEach((name, watcher) -> { |
|||
try { |
|||
watcher.checkAndReload(name); |
|||
} catch (Exception e) { |
|||
log.error("Error checking certificate for {}: {}", name, e.getMessage(), e); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private void discoverAndRegisterSslCredentials() { |
|||
try { |
|||
Map<String, SslCredentialsConfig> sslConfigBeans = applicationContext.getBeansOfType(SslCredentialsConfig.class); |
|||
|
|||
log.info("Found {} SslCredentialsConfig beans", sslConfigBeans.size()); |
|||
|
|||
for (Map.Entry<String, SslCredentialsConfig> entry : sslConfigBeans.entrySet()) { |
|||
String beanName = entry.getKey(); |
|||
SslCredentialsConfig config = entry.getValue(); |
|||
|
|||
try { |
|||
if (!config.isEnabled()) { |
|||
log.debug("Skipping disabled SSL config: {} ({})", config.getName(), beanName); |
|||
continue; |
|||
} |
|||
|
|||
SslCredentials credentials = config.getCredentials(); |
|||
if (credentials == null) { |
|||
log.debug("Skipping uninitialized SSL config: {} ({})", config.getName(), beanName); |
|||
continue; |
|||
} |
|||
|
|||
List<Path> filePaths = credentials.getCertificateFilePaths(); |
|||
if (filePaths == null || filePaths.isEmpty()) { |
|||
log.debug("No file-system certificate paths to watch for: {} ({}) — certificates may be classpath-based", config.getName(), beanName); |
|||
continue; |
|||
} |
|||
|
|||
// Register all configured paths, including those that don't exist yet — the watcher uses
|
|||
// mtime=0 / checksum="" as baseline, so files that appear later (e.g. delayed mounts) are
|
|||
// picked up and trigger a reload on the next poll.
|
|||
List<Path> pathsToWatch = new ArrayList<>(filePaths.size()); |
|||
for (Path filePath : filePaths) { |
|||
if (filePath == null) { |
|||
continue; |
|||
} |
|||
pathsToWatch.add(filePath); |
|||
if (!Files.exists(filePath)) { |
|||
log.warn("Certificate file does not exist yet: {} (from {}) — will be watched and picked up when it appears", |
|||
filePath, config.getName()); |
|||
} |
|||
} |
|||
|
|||
if (!pathsToWatch.isEmpty()) { |
|||
registerWatcher(config.getName(), pathsToWatch, config::onCertificateFileChanged); |
|||
log.info("Registered certificate watcher: {} -> {}", config.getName(), pathsToWatch); |
|||
} |
|||
|
|||
} catch (Exception e) { |
|||
log.error("Error registering watchers for SSL config: {} ({})", config.getName(), beanName, e); |
|||
} |
|||
} |
|||
|
|||
} catch (Exception e) { |
|||
log.error("Error discovering SSL credentials configs", e); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void destroy() throws Exception { |
|||
if (scheduler != null) { |
|||
scheduler.shutdown(); |
|||
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { |
|||
scheduler.shutdownNow(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void afterSingletonsInstantiated() { |
|||
if (!reloadEnabled) { |
|||
log.trace("Auto-reload of certificates is disabled. Skipping initialization..."); |
|||
return; |
|||
} |
|||
log.info("Initializing Certificate Reload Manager..."); |
|||
|
|||
discoverAndRegisterSslCredentials(); |
|||
|
|||
scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager")); |
|||
scheduler.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS); |
|||
} |
|||
|
|||
static class CertificateWatcher { |
|||
private final List<Path> paths; |
|||
private final Runnable reloadCallback; |
|||
private final Map<Path, Long> lastModifiedMap; |
|||
private final Map<Path, String> lastChecksumMap; |
|||
private int consecutiveFailures; |
|||
private String failedCombinedChecksum; |
|||
|
|||
CertificateWatcher(List<Path> paths, Runnable reloadCallback) { |
|||
this.paths = paths; |
|||
this.reloadCallback = reloadCallback; |
|||
this.lastModifiedMap = new HashMap<>(); |
|||
this.lastChecksumMap = new HashMap<>(); |
|||
for (Path path : paths) { |
|||
lastModifiedMap.put(path, getLastModifiedTime(path)); |
|||
lastChecksumMap.put(path, calculateChecksum(path)); |
|||
} |
|||
this.consecutiveFailures = 0; |
|||
} |
|||
|
|||
synchronized void checkAndReload(String name) { |
|||
boolean anyModifiedChanged = false; |
|||
for (Path path : paths) { |
|||
long currentModified = getLastModifiedTime(path); |
|||
Long lastModified = lastModifiedMap.getOrDefault(path, 0L); |
|||
if (currentModified != lastModified) { |
|||
anyModifiedChanged = true; |
|||
break; |
|||
} |
|||
} |
|||
if (!anyModifiedChanged) { |
|||
return; |
|||
} |
|||
|
|||
// Capture mtimes and checksums together before the callback runs.
|
|||
// Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll.
|
|||
Map<Path, Long> currentModifiedTimes = new HashMap<>(); |
|||
Map<Path, String> currentChecksums = new HashMap<>(); |
|||
StringBuilder combined = new StringBuilder(); |
|||
for (Path path : paths) { |
|||
currentModifiedTimes.put(path, getLastModifiedTime(path)); |
|||
String checksum = calculateChecksum(path); |
|||
currentChecksums.put(path, checksum); |
|||
if (!combined.isEmpty()) { |
|||
combined.append("|"); |
|||
} |
|||
combined.append(path).append("=").append(checksum); |
|||
} |
|||
String combinedChecksum = combined.toString(); |
|||
|
|||
// Build old combined checksum for comparison
|
|||
StringBuilder oldCombined = new StringBuilder(); |
|||
for (Path path : paths) { |
|||
if (!oldCombined.isEmpty()) { |
|||
oldCombined.append("|"); |
|||
} |
|||
oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, "")); |
|||
} |
|||
String oldCombinedChecksum = oldCombined.toString(); |
|||
|
|||
if (combinedChecksum.equals(oldCombinedChecksum)) { |
|||
// Content unchanged, just update modification times
|
|||
for (Path path : paths) { |
|||
lastModifiedMap.put(path, currentModifiedTimes.get(path)); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { |
|||
// File content has changed since the last failure - reset and retry
|
|||
consecutiveFailures = 0; |
|||
failedCombinedChecksum = null; |
|||
} |
|||
|
|||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { |
|||
// Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle
|
|||
for (Path path : paths) { |
|||
lastModifiedMap.put(path, currentModifiedTimes.get(path)); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
log.info("Certificate change detected for: {}. Triggering reload...", name); |
|||
reloadCallback.run(); |
|||
for (Path path : paths) { |
|||
lastModifiedMap.put(path, currentModifiedTimes.get(path)); |
|||
lastChecksumMap.put(path, currentChecksums.get(path)); |
|||
} |
|||
consecutiveFailures = 0; |
|||
failedCombinedChecksum = null; |
|||
} catch (Exception e) { |
|||
consecutiveFailures++; |
|||
failedCombinedChecksum = combinedChecksum; |
|||
// Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries
|
|||
// (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum).
|
|||
log.error("Failed to reload certificate for {} (attempt {}/{}): {}", |
|||
name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); |
|||
} |
|||
} |
|||
|
|||
private long getLastModifiedTime(Path path) { |
|||
try { |
|||
if (!Files.exists(path)) { |
|||
return 0; |
|||
} |
|||
return Files.getLastModifiedTime(path).toMillis(); |
|||
} catch (IOException e) { |
|||
return 0; |
|||
} |
|||
} |
|||
|
|||
private String calculateChecksum(Path path) { |
|||
try { |
|||
if (!Files.exists(path)) { |
|||
return ""; |
|||
} |
|||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
|||
byte[] buf = new byte[8192]; |
|||
try (InputStream is = Files.newInputStream(path)) { |
|||
int bytesRead; |
|||
while ((bytesRead = is.read(buf)) != -1) { |
|||
md.update(buf, 0, bytesRead); |
|||
} |
|||
} |
|||
return Base64.getEncoder().encodeToString(md.digest()); |
|||
} catch (Exception e) { |
|||
log.warn("Failed to calculate checksum for certificate file: {}", path, e); |
|||
return ""; |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,185 @@ |
|||
/** |
|||
* 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.server.common.transport.config.ssl; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.Mockito.doNothing; |
|||
import static org.mockito.Mockito.doThrow; |
|||
import static org.mockito.Mockito.verify; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class SslCredentialsConfigTest { |
|||
|
|||
@Mock |
|||
private SslCredentials mockCredentials; |
|||
|
|||
private SslCredentialsConfig config; |
|||
|
|||
@BeforeEach |
|||
public void setup() { |
|||
config = new SslCredentialsConfig("Test SSL Config", false); |
|||
} |
|||
|
|||
@Test |
|||
public void givenConfig_whenCreated_thenShouldHaveCorrectName() { |
|||
assertThat(config.getName()).isEqualTo("Test SSL Config"); |
|||
assertThat(config.isTrustsOnly()).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenTrustsOnlyConfig_whenCreated_thenShouldHaveCorrectTrustsOnly() { |
|||
SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); |
|||
assertThat(trustsOnlyConfig.isTrustsOnly()).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCallback_whenRegistered_thenShouldBeStoredInList() { |
|||
AtomicInteger callCount = new AtomicInteger(0); |
|||
|
|||
config.registerReloadCallback(callCount::incrementAndGet); |
|||
config.setCredentials(mockCredentials); |
|||
|
|||
try { |
|||
doNothing().when(mockCredentials).reload(false); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException(e); |
|||
} |
|||
|
|||
config.onCertificateFileChanged(); |
|||
|
|||
assertThat(callCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMultipleCallbacks_whenCertificateChanged_thenAllShouldBeCalled() throws Exception { |
|||
AtomicInteger callback1Count = new AtomicInteger(0); |
|||
AtomicInteger callback2Count = new AtomicInteger(0); |
|||
AtomicInteger callback3Count = new AtomicInteger(0); |
|||
|
|||
config.registerReloadCallback(callback1Count::incrementAndGet); |
|||
config.registerReloadCallback(callback2Count::incrementAndGet); |
|||
config.registerReloadCallback(callback3Count::incrementAndGet); |
|||
|
|||
config.setCredentials(mockCredentials); |
|||
doNothing().when(mockCredentials).reload(false); |
|||
|
|||
config.onCertificateFileChanged(); |
|||
|
|||
assertThat(callback1Count.get()).isEqualTo(1); |
|||
assertThat(callback2Count.get()).isEqualTo(1); |
|||
assertThat(callback3Count.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCallbackThrowsException_whenCertificateChanged_thenOtherCallbacksShouldStillBeCalled() throws Exception { |
|||
AtomicInteger callback1Count = new AtomicInteger(0); |
|||
AtomicInteger callback2Count = new AtomicInteger(0); |
|||
|
|||
config.registerReloadCallback(() -> { |
|||
callback1Count.incrementAndGet(); |
|||
throw new RuntimeException("Simulated callback failure"); |
|||
}); |
|||
config.registerReloadCallback(callback2Count::incrementAndGet); |
|||
|
|||
config.setCredentials(mockCredentials); |
|||
doNothing().when(mockCredentials).reload(false); |
|||
|
|||
config.onCertificateFileChanged(); |
|||
|
|||
assertThat(callback1Count.get()).isEqualTo(1); |
|||
assertThat(callback2Count.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCredentialsReloadFails_whenCertificateChanged_thenShouldRethrowAndNotCallCallbacks() throws Exception { |
|||
AtomicInteger callbackCount = new AtomicInteger(0); |
|||
|
|||
config.registerReloadCallback(callbackCount::incrementAndGet); |
|||
config.setCredentials(mockCredentials); |
|||
|
|||
doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); |
|||
|
|||
assertThatThrownBy(() -> config.onCertificateFileChanged()) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("Failed to reload SSL credentials"); |
|||
|
|||
assertThat(callbackCount.get()).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCertificateChanged_whenCredentialsReloadSucceeds_thenShouldCallReload() throws Exception { |
|||
config.setCredentials(mockCredentials); |
|||
doNothing().when(mockCredentials).reload(false); |
|||
|
|||
config.onCertificateFileChanged(); |
|||
|
|||
verify(mockCredentials).reload(false); |
|||
} |
|||
|
|||
@Test |
|||
public void givenTrustsOnlyConfig_whenCertificateChanged_thenShouldReloadWithTrustsOnlyTrue() throws Exception { |
|||
SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); |
|||
trustsOnlyConfig.setCredentials(mockCredentials); |
|||
doNothing().when(mockCredentials).reload(true); |
|||
|
|||
trustsOnlyConfig.onCertificateFileChanged(); |
|||
|
|||
verify(mockCredentials).reload(true); |
|||
} |
|||
|
|||
@Test |
|||
public void givenConcurrentCallbackRegistrations_whenCertificateChanged_thenShouldHandleSafely() throws Exception { |
|||
AtomicInteger totalCallbacks = new AtomicInteger(0); |
|||
CountDownLatch startLatch = new CountDownLatch(1); |
|||
CountDownLatch doneLatch = new CountDownLatch(10); |
|||
|
|||
for (int i = 0; i < 10; i++) { |
|||
new Thread(() -> { |
|||
try { |
|||
startLatch.await(); |
|||
config.registerReloadCallback(totalCallbacks::incrementAndGet); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException(e); |
|||
} finally { |
|||
doneLatch.countDown(); |
|||
} |
|||
}).start(); |
|||
} |
|||
|
|||
startLatch.countDown(); |
|||
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
|||
assertThat(completed).isTrue(); |
|||
|
|||
config.setCredentials(mockCredentials); |
|||
doNothing().when(mockCredentials).reload(false); |
|||
|
|||
config.onCertificateFileChanged(); |
|||
|
|||
assertThat(totalCallbacks.get()).isEqualTo(10); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,277 @@ |
|||
/** |
|||
* 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.server.common.transport.config.ssl; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
import org.springframework.boot.autoconfigure.web.ServerProperties; |
|||
import org.springframework.boot.ssl.SslBundle; |
|||
import org.springframework.boot.ssl.SslBundles; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.security.KeyStore; |
|||
import java.security.cert.X509Certificate; |
|||
import java.util.List; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
import java.util.function.Consumer; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
public class SslCredentialsWebServerCustomizerTest { |
|||
|
|||
@Mock |
|||
private ServerProperties mockServerProperties; |
|||
|
|||
@Mock |
|||
private SslCredentialsConfig mockCredentialsConfig; |
|||
|
|||
@Mock |
|||
private SslCredentials mockCredentials; |
|||
|
|||
@Mock |
|||
private KeyStore mockKeyStore; |
|||
|
|||
private SslCredentialsWebServerCustomizer customizer; |
|||
|
|||
@BeforeEach |
|||
public void setup() throws Exception { |
|||
customizer = new SslCredentialsWebServerCustomizer(mockServerProperties); |
|||
ReflectionTestUtils.setField(customizer, "httpServerSslCredentialsConfig", mockCredentialsConfig); |
|||
|
|||
when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); |
|||
when(mockCredentials.getKeyStore()).thenReturn(mockKeyStore); |
|||
when(mockCredentials.getKeyPassword()).thenReturn("password"); |
|||
when(mockCredentials.getKeyAlias()).thenReturn("server"); |
|||
|
|||
X509Certificate mockCert = mock(X509Certificate.class); |
|||
when(mockCert.getEncoded()).thenReturn("TEST_CERT_DATA".getBytes()); |
|||
when(mockCredentials.getCertificateChain()).thenReturn(new X509Certificate[]{mockCert}); |
|||
} |
|||
|
|||
@Test |
|||
public void givenInitialized_whenAfterSingletonsInstantiated_thenShouldRegisterReloadCallback() { |
|||
customizer.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
assertThat(callbackCaptor.getValue()).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenInvoked_thenShouldReloadCertificates() { |
|||
customizer.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
reloadCallback.run(); |
|||
|
|||
verify(mockCredentialsConfig, times(1)).getCredentials(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSslBundles_whenGetBundle_thenShouldReturnValidBundle() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
|
|||
SslBundle bundle = sslBundles.getBundle("default"); |
|||
|
|||
assertThat(bundle).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSslBundles_whenGetBundleNames_thenShouldReturnDefault() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
|
|||
List<String> bundleNames = sslBundles.getBundleNames(); |
|||
|
|||
assertThat(bundleNames).containsExactly("default"); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSslBundles_whenAddUpdateHandler_thenShouldRegisterHandler() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
AtomicInteger handlerCallCount = new AtomicInteger(0); |
|||
Consumer<SslBundle> handler = bundle -> handlerCallCount.incrementAndGet(); |
|||
|
|||
sslBundles.addBundleUpdateHandler("default", handler); |
|||
|
|||
customizer.afterSingletonsInstantiated(); |
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
callbackCaptor.getValue().run(); |
|||
|
|||
assertThat(handlerCallCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSslBundles_whenAddUpdateHandlerForWrongBundle_thenShouldNotRegister() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
AtomicInteger handlerCallCount = new AtomicInteger(0); |
|||
Consumer<SslBundle> handler = bundle -> handlerCallCount.incrementAndGet(); |
|||
|
|||
sslBundles.addBundleUpdateHandler("wrong-bundle", handler); |
|||
|
|||
customizer.afterSingletonsInstantiated(); |
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
callbackCaptor.getValue().run(); |
|||
|
|||
assertThat(handlerCallCount.get()).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMultipleUpdateHandlers_whenReload_thenShouldNotifyAll() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
AtomicInteger handler1CallCount = new AtomicInteger(0); |
|||
AtomicInteger handler2CallCount = new AtomicInteger(0); |
|||
AtomicInteger handler3CallCount = new AtomicInteger(0); |
|||
|
|||
sslBundles.addBundleUpdateHandler("default", bundle -> handler1CallCount.incrementAndGet()); |
|||
sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); |
|||
sslBundles.addBundleUpdateHandler("default", bundle -> handler3CallCount.incrementAndGet()); |
|||
|
|||
customizer.afterSingletonsInstantiated(); |
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
callbackCaptor.getValue().run(); |
|||
|
|||
assertThat(handler1CallCount.get()).isEqualTo(1); |
|||
assertThat(handler2CallCount.get()).isEqualTo(1); |
|||
assertThat(handler3CallCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMultipleReloads_whenTriggered_thenShouldNotifyHandlersEachTime() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
AtomicInteger handlerCallCount = new AtomicInteger(0); |
|||
sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); |
|||
|
|||
customizer.afterSingletonsInstantiated(); |
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
reloadCallback.run(); |
|||
reloadCallback.run(); |
|||
reloadCallback.run(); |
|||
|
|||
assertThat(handlerCallCount.get()).isEqualTo(3); |
|||
} |
|||
|
|||
@Test |
|||
public void givenUpdateHandlerThrowsException_whenReload_thenShouldContinueNotifyingOtherHandlers() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
AtomicInteger handler1CallCount = new AtomicInteger(0); |
|||
AtomicInteger handler2CallCount = new AtomicInteger(0); |
|||
|
|||
sslBundles.addBundleUpdateHandler("default", bundle -> { |
|||
handler1CallCount.incrementAndGet(); |
|||
throw new RuntimeException("Handler 1 failed"); |
|||
}); |
|||
sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); |
|||
|
|||
customizer.afterSingletonsInstantiated(); |
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
callbackCaptor.getValue().run(); |
|||
|
|||
assertThat(handler1CallCount.get()).isEqualTo(1); |
|||
assertThat(handler2CallCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenConcurrentReloads_whenTriggered_thenShouldHandleThreadSafely() throws Exception { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
AtomicInteger handlerCallCount = new AtomicInteger(0); |
|||
CountDownLatch startLatch = new CountDownLatch(1); |
|||
CountDownLatch doneLatch = new CountDownLatch(5); |
|||
|
|||
sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); |
|||
|
|||
customizer.afterSingletonsInstantiated(); |
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
for (int i = 0; i < 5; i++) { |
|||
new Thread(() -> { |
|||
try { |
|||
startLatch.await(); |
|||
reloadCallback.run(); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException(e); |
|||
} finally { |
|||
doneLatch.countDown(); |
|||
} |
|||
}).start(); |
|||
} |
|||
|
|||
startLatch.countDown(); |
|||
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
|||
|
|||
assertThat(completed).isTrue(); |
|||
assertThat(handlerCallCount.get()).isEqualTo(5); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadWithFailingCredentials_whenInvoked_thenShouldHandleGracefully() { |
|||
when(mockCredentialsConfig.getCredentials()).thenThrow(new RuntimeException("Failed to load credentials")); |
|||
|
|||
customizer.afterSingletonsInstantiated(); |
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
|
|||
callbackCaptor.getValue().run(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSslBundle_whenGetBundleMultipleTimes_thenShouldReturnFreshBundle() { |
|||
SslBundles sslBundles = customizer.sslBundles(); |
|||
|
|||
SslBundle bundle1 = sslBundles.getBundle("default"); |
|||
SslBundle bundle2 = sslBundles.getBundle("default"); |
|||
|
|||
assertThat(bundle1).isNotNull(); |
|||
assertThat(bundle2).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenHttpServerSslCredentials_whenCreateBean_thenShouldReturnConfig() { |
|||
SslCredentialsConfig config = customizer.httpServerSslCredentials(); |
|||
|
|||
assertThat(config).isNotNull(); |
|||
assertThat(config.getName()).isEqualTo("HTTP Server SSL Credentials"); |
|||
assertThat(config.isTrustsOnly()).isFalse(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,355 @@ |
|||
/** |
|||
* 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.server.common.transport.service; |
|||
|
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.io.TempDir; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.io.IOException; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.nio.file.attribute.FileTime; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.ScheduledExecutorService; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static java.util.concurrent.TimeUnit.MILLISECONDS; |
|||
import static java.util.concurrent.TimeUnit.SECONDS; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.awaitility.Awaitility.await; |
|||
|
|||
public class CertificateReloadManagerTest { |
|||
|
|||
@TempDir |
|||
Path tempDir; |
|||
|
|||
private CertificateReloadManager certificateReloadManager; |
|||
private Path certFile; |
|||
|
|||
@BeforeEach |
|||
public void setup() throws IOException { |
|||
certificateReloadManager = new CertificateReloadManager(); |
|||
|
|||
certFile = tempDir.resolve("test-cert.pem"); |
|||
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V1\n-----END CERTIFICATE-----\n"); |
|||
} |
|||
|
|||
@AfterEach |
|||
public void teardown() throws Exception { |
|||
if (certificateReloadManager != null) { |
|||
certificateReloadManager.destroy(); |
|||
} |
|||
} |
|||
|
|||
private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { |
|||
Files.writeString(path, content); |
|||
await().atMost(2, SECONDS) |
|||
.pollInterval(10, MILLISECONDS) |
|||
.until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); |
|||
} |
|||
|
|||
private long mtime(Path path) throws IOException { |
|||
return Files.getLastModifiedTime(path).toMillis(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reloadCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reloadCount.get()).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
public void givenOnlyTimestampChanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
long bumpedMtime = Files.getLastModifiedTime(certFile).toMillis() + 5_000L; |
|||
Files.setLastModifiedTime(certFile, FileTime.fromMillis(bumpedMtime)); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reloadCount.get()).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
public void givenWatcherRegistered_whenFileDeleted_thenShouldNotCrash() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
Files.delete(certFile); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reloadCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { |
|||
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); |
|||
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); |
|||
ReflectionTestUtils.setField(certificateReloadManager, "scheduler", scheduler); |
|||
|
|||
certificateReloadManager.destroy(); |
|||
|
|||
assertThat(scheduler.isShutdown()).isTrue(); |
|||
assertThat(scheduler.isTerminated()).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMultipleCertificateFiles_whenOneChanges_thenShouldTriggerReload() throws Exception { |
|||
Path keyFile = tempDir.resolve("test-key.pem"); |
|||
Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); |
|||
|
|||
AtomicInteger certReloadCount = new AtomicInteger(0); |
|||
AtomicInteger keyReloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, certReloadCount::incrementAndGet); |
|||
certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(keyFile); |
|||
writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(keyReloadCount.get()).isEqualTo(1); |
|||
assertThat(certReloadCount.get()).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMultipleWatchers_whenCheckCertificates_thenShouldCheckAll() throws Exception { |
|||
Path cert2File = tempDir.resolve("test-cert2.pem"); |
|||
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); |
|||
|
|||
AtomicInteger reload1Count = new AtomicInteger(0); |
|||
AtomicInteger reload2Count = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); |
|||
certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); |
|||
|
|||
long baseline1 = mtime(certFile); |
|||
long baseline2 = mtime(cert2File); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); |
|||
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reload1Count.get()).isEqualTo(1); |
|||
assertThat(reload2Count.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCallbackThrowsException_whenCheckForChanges_thenShouldContinueWithOtherWatchers() throws Exception { |
|||
Path cert2File = tempDir.resolve("test-cert2.pem"); |
|||
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); |
|||
|
|||
AtomicInteger reload2Count = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert1", certFile, () -> { |
|||
throw new RuntimeException("Simulated reload failure"); |
|||
}); |
|||
certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); |
|||
|
|||
long baseline1 = mtime(certFile); |
|||
long baseline2 = mtime(cert2File); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); |
|||
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reload2Count.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenFileDeletedAndRecreated_whenCheckForChanges_thenShouldTriggerReload() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
Files.delete(certFile); |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadCount.get()).isEqualTo(1); |
|||
|
|||
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadCount.get()).isEqualTo(2); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(certFile); |
|||
for (int i = 0; i < 5; i++) { |
|||
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); |
|||
} |
|||
await().atMost(2, SECONDS) |
|||
.pollInterval(10, MILLISECONDS) |
|||
.until(() -> mtime(certFile) != baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reloadCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenConcurrentChecks_whenCheckForChanges_thenShouldReloadExactlyOnce() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
CountDownLatch startLatch = new CountDownLatch(1); |
|||
CountDownLatch doneLatch = new CountDownLatch(5); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
for (int i = 0; i < 5; i++) { |
|||
new Thread(() -> { |
|||
try { |
|||
startLatch.await(); |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException(e); |
|||
} finally { |
|||
doneLatch.countDown(); |
|||
} |
|||
}).start(); |
|||
} |
|||
|
|||
startLatch.countDown(); |
|||
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
|||
|
|||
assertThat(completed).isTrue(); |
|||
assertThat(reloadCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSameContentRewritten_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
String originalContent = Files.readString(certFile); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reloadCount.get()).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCallbackFailsRepeatedly_whenMaxFailuresReached_thenShouldStopRetrying() throws Exception { |
|||
AtomicInteger reloadAttempts = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
|||
reloadAttempts.incrementAndGet(); |
|||
throw new RuntimeException("Persistent failure"); |
|||
}); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
for (int i = 0; i < 15; i++) { |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
} |
|||
|
|||
assertThat(reloadAttempts.get()).isEqualTo(10); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCallbackFailedPreviously_whenFileChangesAgain_thenShouldResetAndRetry() throws Exception { |
|||
AtomicInteger reloadAttempts = new AtomicInteger(0); |
|||
AtomicInteger shouldFail = new AtomicInteger(1); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
|||
reloadAttempts.incrementAndGet(); |
|||
if (shouldFail.get() == 1) { |
|||
throw new RuntimeException("Transient failure"); |
|||
} |
|||
}); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadAttempts.get()).isEqualTo(1); |
|||
|
|||
shouldFail.set(0); |
|||
long baseline2 = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadAttempts.get()).isEqualTo(2); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { |
|||
AtomicInteger reloadAttempts = new AtomicInteger(0); |
|||
AtomicInteger shouldFail = new AtomicInteger(1); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
|||
reloadAttempts.incrementAndGet(); |
|||
if (shouldFail.get() == 1) { |
|||
throw new RuntimeException("Persistent failure"); |
|||
} |
|||
}); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
for (int i = 0; i < 15; i++) { |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
} |
|||
assertThat(reloadAttempts.get()).isEqualTo(10); |
|||
|
|||
shouldFail.set(0); |
|||
long baseline2 = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadAttempts.get()).isEqualTo(11); |
|||
} |
|||
|
|||
} |
|||
@ -1,5 +1,5 @@ |
|||
diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs
|
|||
index 35c61af..d89462b 100755
|
|||
index 4f7d936..4a98b2c 100755
|
|||
--- a/node_modules/@angular/core/fesm2022/debug_node.mjs
|
|||
+++ b/node_modules/@angular/core/fesm2022/debug_node.mjs
|
|||
@@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) {
|
|||
@ -0,0 +1,20 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="htmlContainerWidgetConfigForm"> |
|||
<tb-html-container-settings formControlName="settings"></tb-html-container-settings> |
|||
</ng-container> |
|||
@ -0,0 +1,62 @@ |
|||
///
|
|||
/// 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.
|
|||
///
|
|||
|
|||
import { Component, HostBinding } from '@angular/core'; |
|||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
|||
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { |
|||
htmlContainerDefaultSettings, |
|||
HtmlContainerWidgetSettings |
|||
} from '@home/components/widget/lib/html/html-container-widget.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-html-container-basic-config', |
|||
templateUrl: './html-container-basic-config.component.html', |
|||
styleUrls: ['../basic-config.scss'], |
|||
standalone: false |
|||
}) |
|||
export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent { |
|||
|
|||
@HostBinding('style.height') height = '100%'; |
|||
|
|||
htmlContainerWidgetConfigForm: UntypedFormGroup; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected widgetConfigComponent: WidgetConfigComponent, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store, widgetConfigComponent); |
|||
} |
|||
|
|||
protected configForm(): UntypedFormGroup { |
|||
return this.htmlContainerWidgetConfigForm; |
|||
} |
|||
|
|||
protected onConfigSet(configData: WidgetConfigComponentData) { |
|||
const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; |
|||
this.htmlContainerWidgetConfigForm = this.fb.group({ |
|||
settings: [settings, []] |
|||
}); |
|||
} |
|||
|
|||
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
|||
this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; |
|||
return this.widgetConfig; |
|||
} |
|||
} |
|||
@ -0,0 +1,318 @@ |
|||
///
|
|||
/// 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.
|
|||
///
|
|||
|
|||
import { |
|||
Component, |
|||
ElementRef, |
|||
Inject, |
|||
Injector, |
|||
Input, |
|||
OnInit, |
|||
Optional, |
|||
Type, |
|||
ViewChild, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { |
|||
htmlContainerDefaultSettings, |
|||
HtmlContainerWidgetSettings, |
|||
HtmlContainerWidgetType, |
|||
WidgetContainerAngularFunction, |
|||
WidgetContainerPlainFunction |
|||
} from '@home/components/widget/lib/html/html-container-widget.models'; |
|||
import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils'; |
|||
import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models'; |
|||
import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs'; |
|||
import cssjs from '@core/css/css'; |
|||
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; |
|||
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; |
|||
import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; |
|||
import { ExceptionData } from '@shared/models/error.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { |
|||
flatModulesWithComponents, |
|||
ModulesWithComponents, |
|||
modulesWithComponentsToTypes, |
|||
ResourcesService |
|||
} from '@core/services/resources.service'; |
|||
import { MODULES_MAP } from '@shared/models/constants'; |
|||
import { IModulesMap } from '@modules/common/modules-map.models'; |
|||
import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-html-container-widget', |
|||
template: '<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>' + |
|||
'@if (widgetErrorData) { <div class="tb-absolute-fill tb-widget-error">\n' + |
|||
' <span [innerHtml]="(\'Widget Error:<br/><br/>\' + widgetErrorData.message) | safe:\'html\'"></span>\n' + |
|||
'</div> }', |
|||
styles: '.tb-widget-error {\n' + |
|||
' display: flex;\n' + |
|||
' align-items: center;\n' + |
|||
' justify-content: center;\n' + |
|||
' background: rgba(255, 255, 255, .5);\n' + |
|||
'\n' + |
|||
' span {\n' + |
|||
' color: #f00;\n' + |
|||
' }\n' + |
|||
' }', |
|||
encapsulation: ViewEncapsulation.None, |
|||
standalone: false |
|||
}) |
|||
export class HtmlContainerWidgetComponent implements OnInit { |
|||
|
|||
@ViewChild('container', {static: true}) |
|||
containerElmRef: ElementRef<HTMLElement>; |
|||
|
|||
@ViewChild('angularContainer', {static: true}) |
|||
angularContainer: TbAnchorComponent; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
private containerInstanceComponentType: Type<any>; |
|||
|
|||
private settings: HtmlContainerWidgetSettings; |
|||
|
|||
widgetErrorData: ExceptionData; |
|||
|
|||
constructor(private elementRef: ElementRef<HTMLElement>, |
|||
@Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap, |
|||
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>, |
|||
@Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type<any>, |
|||
@Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type<any>, |
|||
private dynamicComponentFactoryService: DynamicComponentFactoryService, |
|||
private utils: UtilsService, |
|||
private resources: ResourcesService) {} |
|||
|
|||
ngOnInit(): void { |
|||
this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})}; |
|||
this.loadWidgetResources().subscribe( |
|||
{ |
|||
next: () => { |
|||
if (this.settings.type === HtmlContainerWidgetType.PLAIN) { |
|||
this.initPlain(); |
|||
} else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) { |
|||
this.initAngular(); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
); |
|||
} |
|||
|
|||
private initPlain(): void { |
|||
try { |
|||
if (isNotEmptyStr(this.settings.css)) { |
|||
const cssParser = new cssjs(); |
|||
cssParser.testMode = false; |
|||
const namespace = 'html-container-' + hashCode(this.settings.css); |
|||
cssParser.cssPreviewNamespace = namespace; |
|||
cssParser.createStyleElement(namespace, this.settings.css); |
|||
$(this.elementRef.nativeElement).addClass(namespace); |
|||
} |
|||
if (isNotEmptyStr(this.settings.html)) { |
|||
$(this.containerElmRef.nativeElement).html(this.settings.html); |
|||
} |
|||
this.compileAndExecutePlainFunction(); |
|||
} catch (e) { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
|
|||
private compileAndExecutePlainFunction(): void { |
|||
if (isNotEmptyTbFunction(this.settings.js)) { |
|||
const jsFunction: Observable<CompiledTbFunction<WidgetContainerPlainFunction>> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']); |
|||
jsFunction.subscribe({ |
|||
next: (containerFunction) => { |
|||
try { |
|||
containerFunction.execute(this.ctx, this.containerElmRef.nativeElement); |
|||
} catch (e) { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private initAngular(): void { |
|||
this.loadAngularModules().subscribe( |
|||
{ |
|||
next: (imports) => { |
|||
this.compileAngularFunction().subscribe( |
|||
{ |
|||
next: (containerFunction) => { |
|||
try { |
|||
this.initAngularComponent(imports, containerFunction); |
|||
} catch (e) { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
); |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
); |
|||
} |
|||
|
|||
private compileAngularFunction(): Observable<CompiledTbFunction<WidgetContainerAngularFunction>> { |
|||
if (isNotEmptyTbFunction(this.settings.js)) { |
|||
return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
|
|||
private initAngularComponent(imports?: Type<any>[], containerFunction?: CompiledTbFunction<WidgetContainerAngularFunction>): void { |
|||
this.angularContainer.viewContainerRef.clear(); |
|||
const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this); |
|||
const template = this.settings.html || ''; |
|||
const styles: string[] = []; |
|||
if (isNotEmptyStr(this.settings.css)) { |
|||
styles.push(this.settings.css); |
|||
} |
|||
let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule]; |
|||
if (imports && imports.length) { |
|||
compileModules = compileModules.concat(imports); |
|||
} |
|||
const self = () => this; |
|||
this.dynamicComponentFactoryService.createDynamicComponent( |
|||
class TbContainerInstance { |
|||
ngOnInit(): void { |
|||
if (containerFunction) { |
|||
const instance = self(); |
|||
try { |
|||
containerFunction.apply(this, [instance.ctx]); |
|||
} catch (e) { |
|||
instance.handleWidgetException(e); |
|||
} |
|||
} |
|||
} |
|||
ngOnDestroy(): void { |
|||
destroyContainerInstanceResources(); |
|||
} |
|||
}, |
|||
template, |
|||
compileModules, |
|||
true, styles |
|||
).subscribe({ |
|||
next: (componentType) => { |
|||
this.containerInstanceComponentType = componentType; |
|||
const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); |
|||
try { |
|||
this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, |
|||
{index: 0, injector}); |
|||
|
|||
} catch (error) { |
|||
this.handleWidgetException(error); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private destroyContainerInstanceResources() { |
|||
if (this.containerInstanceComponentType) { |
|||
this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType); |
|||
this.containerInstanceComponentType = null; |
|||
} |
|||
} |
|||
|
|||
private handleWidgetException(e: any) { |
|||
console.error(e); |
|||
this.widgetErrorData = this.utils.processWidgetException(e); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
|
|||
private loadWidgetResources(): Observable<any> { |
|||
const resourceTasks: Observable<string>[] = []; |
|||
this.settings.resources.filter(r => !r.isModule).forEach( |
|||
(resource) => { |
|||
resourceTasks.push( |
|||
this.resources.loadResource(resource.url).pipe( |
|||
catchError(() => of(`Failed to load widget resource: '${resource.url}'`)) |
|||
) |
|||
); |
|||
} |
|||
); |
|||
if (resourceTasks.length) { |
|||
return forkJoin(resourceTasks).pipe( |
|||
switchMap(msgs => { |
|||
let errors: string[]; |
|||
if (msgs && msgs.length) { |
|||
errors = msgs.filter(msg => msg && msg.length > 0); |
|||
} |
|||
if (errors && errors.length) { |
|||
return throwError(() => new Error(errors.join('<br/>'))); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
)); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
|
|||
private loadAngularModules(): Observable<Type<any>[]> { |
|||
const modulesTasks: Observable<ModulesWithComponents | string>[] = []; |
|||
this.settings.resources.filter(r => r.isModule).forEach( |
|||
(resource) => { |
|||
modulesTasks.push( |
|||
this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe( |
|||
catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`)) |
|||
) |
|||
); |
|||
} |
|||
); |
|||
if (modulesTasks.length) { |
|||
return forkJoin(modulesTasks).pipe( |
|||
map(res => { |
|||
const msg = res.find(r => typeof r === 'string'); |
|||
if (msg) { |
|||
return msg as string; |
|||
} else { |
|||
const modulesWithComponentsList = res as ModulesWithComponents[]; |
|||
return flatModulesWithComponents(modulesWithComponentsList); |
|||
} |
|||
}), |
|||
switchMap(modulesWithComponentsList => { |
|||
if (typeof modulesWithComponentsList === 'string') { |
|||
return throwError(() => new Error(modulesWithComponentsList)); |
|||
} else { |
|||
const modules = modulesWithComponentsToTypes(modulesWithComponentsList); |
|||
return of(modules); |
|||
} |
|||
}) |
|||
); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
} |
|||
@ -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.
|
|||
///
|
|||
|
|||
import { TbFunction } from '@shared/models/js-function.models'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models'; |
|||
import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models'; |
|||
import { WidgetResource } from '@shared/models/widget.models'; |
|||
|
|||
export enum HtmlContainerWidgetType { |
|||
PLAIN = 'PLAIN', |
|||
ANGULAR = 'ANGULAR' |
|||
} |
|||
|
|||
export interface HtmlContainerWidgetSettings { |
|||
type: HtmlContainerWidgetType; |
|||
html: string; |
|||
css: string; |
|||
js: TbFunction; |
|||
resources: WidgetResource[]; |
|||
} |
|||
|
|||
export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = { |
|||
type: HtmlContainerWidgetType.PLAIN, |
|||
html: '', |
|||
css: '', |
|||
js: '', |
|||
resources: [], |
|||
}; |
|||
|
|||
export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void; |
|||
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void; |
|||
|
|||
const containerFunctionCompletions: TbEditorCompletions = { |
|||
...{ |
|||
ctx: { |
|||
meta: 'argument', |
|||
type: widgetContextCompletions.ctx.type, |
|||
description: widgetContextCompletions.ctx.description, |
|||
children: widgetContextCompletions.ctx.children |
|||
} |
|||
} |
|||
}; |
|||
|
|||
export const AngularContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions); |
|||
|
|||
export const HTMLContainerFunctionEditorCompleter = new TbEditorCompleter( |
|||
{...containerFunctionCompletions, |
|||
container: { |
|||
meta: 'argument', |
|||
type: 'HTMLElement', |
|||
description: 'Container element of the widget' |
|||
}} |
|||
); |
|||
|
|||
@ -0,0 +1,128 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="htmlContainerSettingsForm"> |
|||
<div class="tb-form-panel no-padding no-border relative h-full"> |
|||
<div class="flex flex-row items-center"> |
|||
<tb-toggle-select formControlName="type"> |
|||
<tb-toggle-option [value]="HtmlContainerWidgetType.PLAIN">{{ 'widgets.html-container.type-plain' | translate }}</tb-toggle-option> |
|||
<tb-toggle-option [value]="HtmlContainerWidgetType.ANGULAR">{{ 'widgets.html-container.type-angular' | translate }}</tb-toggle-option> |
|||
</tb-toggle-select> |
|||
</div> |
|||
<div class="tb-html-container-settings-panel flex flex-1 flex-col" tb-fullscreen [fullscreen]="fullscreen"> |
|||
<div class="tb-action-expand-button flex flex-row items-center justify-end"> |
|||
<button mat-stroked-button |
|||
matTooltip="{{ 'widget.toggle-fullscreen' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="toggleFullScreen()"> |
|||
<mat-icon>{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon> |
|||
<span>{{ (fullscreen ? 'fullscreen.exit' : 'fullscreen.fullscreen') | translate }}</span> |
|||
</button> |
|||
</div> |
|||
<div class="flex flex-1"> |
|||
<mat-tab-group #leftPanel |
|||
[mat-stretch-tabs]="fullscreen" |
|||
[selectedIndex]="fullscreen ? 2 : 3" |
|||
[animationDuration]="tabsAnimationDuration" |
|||
[disablePagination]="fullscreen" |
|||
class="tb-content" |
|||
[class.flex-1]="!fullscreen"> |
|||
<mat-tab #resourceTab="matTab"> |
|||
<ng-template mat-tab-label> |
|||
<div [matBadge]="resourcesFormArray.length" [matBadgeHidden]="resourceTab.isActive || !resourcesFormArray.length" |
|||
matBadgeSize="small">{{ 'widgets.html-container.resources' | translate }}</div> |
|||
</ng-template> |
|||
<div class="flex flex-col gap-2 pt-4" [class.px-2]="fullscreen"> |
|||
@if (resourcesFormArray.length) { |
|||
@for (resourceControl of resourcesControls; track resourceControl; let i = $index) { |
|||
<div class="tb-form-row no-border no-padding" [formGroup]="resourceControl"> |
|||
<tb-resource-autocomplete class="flex-1" |
|||
formControlName="url" |
|||
inlineField |
|||
hideRequiredMarker required |
|||
[allowAutocomplete]="resourceControl.get('isModule').value && htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR" |
|||
placeholder="{{ 'widget.resource-url' | translate }}"> |
|||
</tb-resource-autocomplete> |
|||
@if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { |
|||
<mat-checkbox formControlName="isModule"> |
|||
{{ 'widget.resource-is-extension' | translate }} |
|||
</mat-checkbox> |
|||
} |
|||
<button mat-icon-button color="primary" |
|||
(click)="removeResource(i)" |
|||
matTooltip="{{'widget.remove-resource' | translate}}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
} |
|||
} @else { |
|||
<span translate |
|||
class="tb-prompt flex items-center justify-center">widgets.html-container.no-resources</span> |
|||
} |
|||
<div> |
|||
<button mat-raised-button color="primary" |
|||
(click)="addResource()" |
|||
matTooltip="{{'widget.add-resource' | translate}}" |
|||
matTooltipPosition="above"> |
|||
<span translate>action.add</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</mat-tab> |
|||
<mat-tab label="{{ 'widgets.html-container.css' | translate }}"> |
|||
<tb-css class="flex-1" |
|||
[fillHeight]="true" |
|||
formControlName="css" |
|||
label="{{ 'widgets.html-container.css' | translate }}"> |
|||
</tb-css> |
|||
</mat-tab> |
|||
<mat-tab label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}"> |
|||
<tb-html class="flex-1" |
|||
[fillHeight]="true" |
|||
formControlName="html" |
|||
label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}"> |
|||
</tb-html> |
|||
</mat-tab> |
|||
@if (!fullscreen) { |
|||
<mat-tab label="{{ 'widgets.html-container.java-script' | translate }}"> |
|||
<ng-container *ngTemplateOutlet="javascript"></ng-container> |
|||
</mat-tab> |
|||
} |
|||
</mat-tab-group> |
|||
<div #rightPanel class="tb-content flex" [class.!hidden]="!fullscreen"> |
|||
@if (fullscreen) { |
|||
<ng-container *ngTemplateOutlet="javascript"></ng-container> |
|||
} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</ng-container> |
|||
<ng-template #javascript> |
|||
<ng-container [formGroup]="htmlContainerSettingsForm"> |
|||
<tb-js-func class="flex-1" |
|||
[fillHeight]="true" |
|||
formControlName="js" |
|||
[globalVariables]="functionScopeVariables" |
|||
[editorCompleter]="containerFunctionEditorCompleter" |
|||
[functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']" |
|||
withModules |
|||
functionTitle="{{ 'widgets.html-container.js-function' | translate }}"> |
|||
</tb-js-func> |
|||
</ng-container> |
|||
</ng-template> |
|||
@ -0,0 +1,128 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
|
|||
.tb-html-container-settings { |
|||
height: 100%; |
|||
} |
|||
|
|||
.tb-html-container-settings .tb-html-container-settings-panel, .tb-html-container-settings-panel { |
|||
position: relative; |
|||
background: #fff; |
|||
.mat-mdc-tab-body-wrapper { |
|||
position: relative; |
|||
top: 0; |
|||
flex: 1; |
|||
} |
|||
.tb-action-expand-button { |
|||
position: absolute; |
|||
top: 4px; |
|||
right: 0; |
|||
z-index: 2; |
|||
} |
|||
.gutter { |
|||
display: none; |
|||
background-color: #eee; |
|||
background-repeat: no-repeat; |
|||
background-position: 50%; |
|||
&.gutter-horizontal { |
|||
cursor: col-resize; |
|||
background-image: url("../../../../../../../../../assets/split.js/grips/vertical.png"); |
|||
} |
|||
} |
|||
.tb-js-func { |
|||
&:not(.tb-fullscreen) { |
|||
&.tb-hide-brackets { |
|||
padding-bottom: 0; |
|||
} |
|||
} |
|||
} |
|||
.tb-html { |
|||
position: relative; |
|||
&:not(.tb-fullscreen) { |
|||
padding-bottom: 0; |
|||
} |
|||
.tb-html-toolbar { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 8px; |
|||
z-index: 8; |
|||
.tb-title { |
|||
display: none; |
|||
} |
|||
} |
|||
.tb-html-content-panel { |
|||
border-top: none; |
|||
height: 100%; |
|||
} |
|||
} |
|||
.tb-css { |
|||
position: relative; |
|||
&:not(.tb-fullscreen) { |
|||
.tb-css-content-panel { |
|||
margin: 0; |
|||
} |
|||
} |
|||
.tb-css-toolbar { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 8px; |
|||
z-index: 8; |
|||
.tb-title { |
|||
display: none; |
|||
} |
|||
} |
|||
.tb-css-content-panel { |
|||
border-top: none; |
|||
height: 100%; |
|||
} |
|||
} |
|||
&.tb-fullscreen { |
|||
padding: 8px; |
|||
gap: 8px; |
|||
.tb-action-expand-button { |
|||
position: relative; |
|||
top: 0; |
|||
right: 0; |
|||
} |
|||
.gutter { |
|||
display: block; |
|||
} |
|||
.tb-content { |
|||
border: 1px solid #c0c0c0; |
|||
.tb-html { |
|||
.tb-html-content-panel { |
|||
border: none; |
|||
} |
|||
} |
|||
.tb-css { |
|||
.tb-css-content-panel { |
|||
border: none; |
|||
} |
|||
} |
|||
.tb-js-func { |
|||
padding-top: 8px; |
|||
.tb-js-func-toolbar { |
|||
padding: 0 5px; |
|||
} |
|||
.tb-js-func-panel { |
|||
border-left: none; |
|||
border-right: none; |
|||
border-bottom: none; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue