From 67b7f7bcf897cce90ab7681afb75423d6d1b510d Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 11 Sep 2025 17:17:11 +0200 Subject: [PATCH 1/8] Add pre-login notice configuration to admin-api, expose with api/v1/frontend/configuration --- ext/hivemq-edge-openapi-2025.14-SNAPSHOT.yaml | 109 ++- .../components/GatewayConfiguration.java | 47 +- .../api/model/components/PreLoginNotice.java | 78 ++ .../resources/impl/FrontendResourceImpl.java | 146 ++-- .../entity/api/AdminApiEntity.java | 64 +- .../entity/api/PreLoginNoticeEntity.java | 91 ++ .../configuration/reader/ApiConfigurator.java | 113 +-- .../reader/ConfigFileReaderWriter.java | 809 +++++++++--------- .../service/ApiConfigurationService.java | 22 +- .../impl/ApiConfigurationServiceImpl.java | 44 +- hivemq-edge/src/main/resources/config.xsd | 10 + .../com/hivemq/api/JaxrsResourceTests.java | 4 +- .../reader/ConfigFileReaderTest.java | 2 +- .../reader/ConfigFileReaderWriterTest.java | 7 +- .../reader/ProtocolAdapterExtractorTest.java | 14 +- .../writer/ConfigFileWriterTest.java | 4 +- .../src/test/resources/test-config.xml | 3 + 17 files changed, 910 insertions(+), 657 deletions(-) create mode 100644 hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java create mode 100644 hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java diff --git a/ext/hivemq-edge-openapi-2025.14-SNAPSHOT.yaml b/ext/hivemq-edge-openapi-2025.14-SNAPSHOT.yaml index bd3e2fb118..3dee955c63 100644 --- a/ext/hivemq-edge-openapi-2025.14-SNAPSHOT.yaml +++ b/ext/hivemq-edge-openapi-2025.14-SNAPSHOT.yaml @@ -101,7 +101,7 @@ paths: responses: default: content: - '*/*': {} + '*/*': { } description: default response /api/v1/auth/authenticate: post: @@ -196,8 +196,8 @@ paths: Get all policies. This endpoint returns the content of the policies with the content-type `application/json`. + - operationId: getAllBehaviorPolicies parameters: - description: 'Comma-separated list of fields to include in the response. Allowed values are: id, createdAt, lastUpdatedAt, deserialization, matching, behavior, onTransitions' @@ -247,7 +247,7 @@ paths: clientIdRegex: .* behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -265,7 +265,7 @@ paths: clientIdRegex: .* behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -288,7 +288,7 @@ paths: clientIdRegex: .* behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -306,7 +306,7 @@ paths: clientIdRegex: .* behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -340,7 +340,7 @@ paths: clientIdRegex: .* behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -387,7 +387,7 @@ paths: version: latest behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -425,10 +425,10 @@ paths: schema: schemaId: schema version: latest - arguments: {} + arguments: { } behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -479,8 +479,8 @@ paths: delete: description: |- Deletes an existing policy. + - operationId: deleteBehaviorPolicy parameters: - description: The identifier of the policy to delete. @@ -537,8 +537,8 @@ paths: Get a specific policy. This endpoint returns the content of the policy with the content-type `application/json`. + - operationId: getBehaviorPolicy parameters: - description: The identifier of the policy. @@ -577,10 +577,10 @@ paths: schema: schemaId: schema version: latest - arguments: {} + arguments: { } behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -614,7 +614,7 @@ paths: Update a behavior policy The path parameter 'policyId' must match the 'id' of the policy in the request body. - + operationId: updateBehaviorPolicy parameters: - description: The identifier of the policy. @@ -649,7 +649,7 @@ paths: version: latest behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -687,10 +687,10 @@ paths: schema: schemaId: schema version: latest - arguments: {} + arguments: { } behavior: id: Mqtt.events - arguments: {} + arguments: { } onTransitions: - fromState: Any.* toState: Any.* @@ -809,8 +809,8 @@ paths: Get all data policies. This endpoint returns the content of the policies with the content-type `application/json`. + - operationId: getAllDataPolicies parameters: - description: 'Comma-separated list of fields to include in the response. Allowed values are: id, createdAt, lastUpdatedAt, matching, validation, onSuccess, onFailure' @@ -1187,8 +1187,8 @@ paths: delete: description: |- Deletes an existing data policy. + - operationId: deleteDataPolicy parameters: - description: The identifier of the data policy to delete. @@ -1239,8 +1239,8 @@ paths: Get a specific data policy. This endpoint returns the content of the policy with the content-type `application/json`. + - operationId: getDataPolicy parameters: - description: The identifier of the policy. @@ -1332,7 +1332,7 @@ paths: The path parameter 'policyId' must match the 'id' of the policy in the request body. The matching part of policies cannot be changed with an update. - + operationId: updateDataPolicy parameters: - description: The identifier of the policy. @@ -1513,8 +1513,8 @@ paths: title: Mqtt.events description: This FSM does not require any arguments. type: object - required: [] - properties: {} + required: [ ] + properties: { } - if: type: object properties: @@ -1527,8 +1527,8 @@ paths: title: Publish.duplicate options description: This FSM does not require any arguments. type: object - required: [] - properties: {} + required: [ ] + properties: { } schema: $ref: '#/components/schemas/JsonNode' description: Success @@ -1596,7 +1596,7 @@ paths: - title: Mqtt.drop description: Drops the MQTT packet that is currently processed type: object - required: [] + required: [ ] metaData: isTerminal: false isDataOnly: false @@ -1628,12 +1628,12 @@ paths: - title: Mqtt.disconnect description: Disconnects the client type: object - required: [] + required: [ ] metaData: isTerminal: true isDataOnly: false hasArguments: false - properties: {} + properties: { } - title: Serdes.deserialize description: Deserializes a binary MQTT message payload into a data object based on the configured JSON Schema or Protobuf schema. type: object @@ -1749,8 +1749,8 @@ paths: Get all schemas. This endpoint returns the content of the schemas with the content-type `application/json`. + - operationId: getAllSchemas parameters: - description: 'Comma-separated list of fields to include in the response. Allowed values are: id, type, schemaDefinition, createdAt' @@ -1959,8 +1959,8 @@ paths: delete: description: |- Deletes the selected schema and all associated versions of the schema. + - operationId: deleteSchema parameters: - description: The schema identifier of the schema versions to delete. @@ -2017,8 +2017,8 @@ paths: Get a specific schema. This endpoint returns the content of the latest version of the schema with the content-type `application/json`. + - operationId: getSchema parameters: - description: The identifier of the schema. @@ -2487,7 +2487,7 @@ paths: imageUrl: '' external: true modules: - items: [] + items: [ ] extensions: items: - id: extension-1 @@ -2640,12 +2640,12 @@ paths: password: '*****' loopPreventionEnabled: true loopPreventionHopCount: 1 - remoteSubscriptions: [] + remoteSubscriptions: [ ] localSubscriptions: - filters: - '#' destination: prefix/{#}/bridge/${bridge.name} - excludes: [] + excludes: [ ] customUserProperties: - key: test1 value: test2 @@ -2656,8 +2656,8 @@ paths: keystorePassword: '' privateKeyPassword: '' truststorePassword: '' - protocols: [] - cipherSuites: [] + protocols: [ ] + cipherSuites: [ ] keystoreType: JKS truststoreType: JKS verifyHostname: true @@ -2777,12 +2777,12 @@ paths: password: password loopPreventionEnabled: true loopPreventionHopCount: 1 - remoteSubscriptions: [] + remoteSubscriptions: [ ] localSubscriptions: - filters: - '#' destination: prefix/{#}/bridge/${bridge.name} - excludes: [] + excludes: [ ] customUserProperties: - key: test1 value: test2 @@ -2793,8 +2793,8 @@ paths: keystorePassword: '' privateKeyPassword: '' truststorePassword: '' - protocols: [] - cipherSuites: [] + protocols: [ ] + cipherSuites: [ ] keystoreType: JKS truststoreType: JKS verifyHostname: true @@ -2969,7 +2969,7 @@ paths: event-list-result: description: Example response with several events. summary: Event List result - value: {} + value: { } schema: $ref: '#/components/schemas/EventList' description: Success @@ -3219,7 +3219,7 @@ paths: description: '' nodeType: VALUE selectable: true - children: [] + children: [ ] schema: $ref: '#/components/schemas/ValuesTree' description: Success @@ -5103,6 +5103,27 @@ components: $ref: '#/components/schemas/Link' required: - items + PreLoginNotice: + type: object + description: The definition of a notice to be presented to the users before login + properties: + enabled: + type: boolean + description: Indicates whether the pre-login notice is enabled or not + default: false + title: + type: string + description: The title of the pre-login notice, also presented to the user + message: + type: string + description: The full text of the pre-login notice + consent: + type: string + description: An optional text for a consent checkbox that, if present, users will need to check to continue to the login itself + required: + - enabled + - message + - title EnvironmentProperties: type: object description: A map of properties relating to the installation @@ -5256,6 +5277,8 @@ components: trackingAllowed: type: boolean description: Is the tracking of user actions allowed. + preLoginNotice: + $ref: '#/components/schemas/PreLoginNotice' Notification: type: object description: List of result items that are returned by this endpoint diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/components/GatewayConfiguration.java b/hivemq-edge/src/main/java/com/hivemq/api/model/components/GatewayConfiguration.java index 78d920c025..9bf3a1f88e 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/components/GatewayConfiguration.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/components/GatewayConfiguration.java @@ -18,57 +18,58 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.hivemq.api.model.firstuse.FirstUseInformation; -import org.jetbrains.annotations.NotNull; import io.swagger.v3.oas.annotations.media.Schema; +import org.jetbrains.annotations.NotNull; -/** - * @author Simon L Johnson - */ public class GatewayConfiguration { @JsonProperty("environment") @Schema(description = "A map of properties relating to the installation", nullable = true) - private @NotNull EnvironmentProperties environment; + private final @NotNull EnvironmentProperties environment; @JsonProperty("cloudLink") @Schema(description = "A referral link to HiveMQ Cloud") - private @NotNull Link cloudLink; + private final @NotNull Link cloudLink; @JsonProperty("gitHubLink") @Schema(description = "A link to the GitHub Project") - private @NotNull Link gitHubLink; + private final @NotNull Link gitHubLink; @JsonProperty("documentationLink") @Schema(description = "A link to the documentation Project") - private @NotNull Link documentationLink; + private final @NotNull Link documentationLink; @JsonProperty("firstUseInformation") @Schema(description = "Information relating to the firstuse experience") - private @NotNull FirstUseInformation firstUseInformation; + private final @NotNull FirstUseInformation firstUseInformation; @JsonProperty("ctas") @Schema(description = "The calls main to action") - private @NotNull LinkList ctas; + private final @NotNull LinkList ctas; @JsonProperty("resources") @Schema(description = "A list of resources to render") - private @NotNull LinkList resources; + private final @NotNull LinkList resources; @JsonProperty("modules") @Schema(description = "The modules available for installation") - private @NotNull ModuleList modules; + private final @NotNull ModuleList modules; @JsonProperty("extensions") @Schema(description = "The extensions available for installation") - private @NotNull ExtensionList extensions; + private final @NotNull ExtensionList extensions; @JsonProperty("hivemqId") @Schema(description = "The current id of hivemq edge. Changes at restart.") - private @NotNull String hivemqId; + private final @NotNull String hivemqId; @JsonProperty("trackingAllowed") @Schema(description = "Is the tracking of user actions allowed.") - private boolean trackingAllowed; + private final boolean trackingAllowed; + + @JsonProperty("preLoginNotice") + @Schema(description = "Pre-login notice configuration") + private final @NotNull PreLoginNotice preLoginNotice; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) public GatewayConfiguration( @@ -82,7 +83,8 @@ public GatewayConfiguration( @JsonProperty("modules") final @NotNull ModuleList modules, @JsonProperty("extensions") final @NotNull ExtensionList extensions, @JsonProperty("hivemqId") final @NotNull String hivemqId, - @JsonProperty("trackingAllowed")final boolean trackingAllowed) { + @JsonProperty("trackingAllowed") final boolean trackingAllowed, + @JsonProperty("preLoginNotice") final PreLoginNotice preLoginNotice) { this.environment = environment; this.cloudLink = cloudLink; this.gitHubLink = gitHubLink; @@ -94,6 +96,7 @@ public GatewayConfiguration( this.extensions = extensions; this.hivemqId = hivemqId; this.trackingAllowed = trackingAllowed; + this.preLoginNotice = preLoginNotice; } public @NotNull EnvironmentProperties getEnvironment() { @@ -131,4 +134,16 @@ public GatewayConfiguration( public @NotNull ExtensionList getExtensions() { return extensions; } + + public @NotNull String getHivemqId() { + return hivemqId; + } + + public boolean isTrackingAllowed() { + return trackingAllowed; + } + + public @NotNull PreLoginNotice getPreLoginNotice() { + return preLoginNotice; + } } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java new file mode 100644 index 0000000000..a7fdc0ae5e --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java @@ -0,0 +1,78 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * 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 com.hivemq.api.model.components; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.util.Objects.requireNonNullElse; + +public class PreLoginNotice { + + @JsonProperty("enabled") + @Schema(description = "Whether the pre login notice should be shown prior to login in") + private final @NotNull Boolean enabled; + + @JsonProperty("title") + @Schema(description = "The title of the notice") + private final @Nullable String title; + + @JsonProperty("message") + @Schema(description = "The message of the notice") + private final @Nullable String message; + + @JsonProperty("consent") + @Schema(description = "The message of the notice") + private final @Nullable String consent; + + public PreLoginNotice() { + this(false, null, null, null); + } + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public PreLoginNotice( + final @Nullable Boolean enabled, + final @Nullable String title, + final @Nullable String message, + final @Nullable String consent) { + this.enabled = requireNonNullElse(enabled, false); + if (this.enabled && (title == null || title.isEmpty() || message == null || message.isEmpty())) { + throw new IllegalArgumentException("Title and message cannot be null or empty when enabled"); + } + this.title = title; + this.message = message; + this.consent = consent; + } + + public boolean getEnabled() { + return enabled; + } + + public @Nullable String getTitle() { + return title; + } + + public @Nullable String getMessage() { + return message; + } + + public @Nullable String getConsent() { + return consent; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/FrontendResourceImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/FrontendResourceImpl.java index f2c9b5261c..d04b3b0fe9 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/FrontendResourceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/FrontendResourceImpl.java @@ -25,6 +25,7 @@ import com.hivemq.api.model.components.ModuleList; import com.hivemq.api.model.components.Notification; import com.hivemq.api.model.components.NotificationList; +import com.hivemq.api.model.components.PreLoginNotice; import com.hivemq.api.model.firstuse.FirstUseInformation; import com.hivemq.api.utils.ApiUtils; import com.hivemq.api.utils.LoremIpsum; @@ -32,7 +33,6 @@ import com.hivemq.configuration.info.SystemInformation; import com.hivemq.configuration.service.ConfigurationService; import com.hivemq.edge.HiveMQCapabilityService; -import com.hivemq.edge.HiveMQEdgeConstants; import com.hivemq.edge.HiveMQEdgeRemoteService; import com.hivemq.edge.ModulesAndExtensionsService; import com.hivemq.edge.api.FrontendApi; @@ -40,19 +40,18 @@ import com.hivemq.edge.api.model.CapabilityList; import com.hivemq.http.core.UsernamePasswordRoles; import com.hivemq.protocols.ProtocolAdapterManager; -import org.jetbrains.annotations.NotNull; - import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; -import java.util.HashMap; +import org.jetbrains.annotations.NotNull; + import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; -/** - * @author Simon L Johnson - */ +import static com.hivemq.edge.HiveMQEdgeConstants.CONFIGURATION_EXPORT_ENABLED; +import static com.hivemq.edge.HiveMQEdgeConstants.MUTABLE_CONFIGURAION_ENABLED; +import static com.hivemq.edge.HiveMQEdgeConstants.VERSION_PROPERTY; + public class FrontendResourceImpl extends AbstractApi implements FrontendApi { private final @NotNull ConfigurationService configurationService; @@ -81,10 +80,17 @@ public FrontendResourceImpl( this.hivemqId = hivemqId; } + private static @NotNull Capability fromModel(final @NotNull com.hivemq.api.model.capabilities.Capability cap) { + return Capability.builder() + .id(Capability.IdEnum.fromString(cap.getId())) + .description(cap.getDescription()) + .displayName(cap.getDisplayName()) + .build(); + } + @Override public @NotNull Response getConfiguration() { - - final GatewayConfiguration configuration = new GatewayConfiguration(getEnvironmentProperties(), + return Response.ok(new GatewayConfiguration(getEnvironmentProperties(), getCloudLink(), getGitHubLink(), getDocumentationLink(), @@ -94,13 +100,48 @@ public FrontendResourceImpl( getModules(), getExtensions(), hivemqId.get(), - configurationService.usageTrackingConfiguration().isUsageTrackingEnabled()); - return Response.ok(configuration).build(); + configurationService.usageTrackingConfiguration().isUsageTrackingEnabled(), + configurationService.apiConfiguration().getPreLoginNotice())).build(); } + @Override + public @NotNull Response getNotifications() { + final ImmutableList.Builder<@NotNull Notification> notifs = new ImmutableList.Builder<>(); + final Optional lastUpdate = configurationService.getLastUpdateTime(); + if (!configurationService.gatewayConfiguration().isMutableConfigurationEnabled() && + configurationService.gatewayConfiguration().isConfigurationExportEnabled() && + lastUpdate.isPresent() && + lastUpdate.get() > System.currentTimeMillis() - (60000 * 5)) { + notifs.add(new Notification(Notification.LEVEL.NOTICE, + "Configuration Has Changed", + "The gateway configuration has recently been modify. In order to persist these changes across runtimes, please export your configuration for use in your containers.", + new Link("Download XML Configuration", + "/configuration-download", + null, + null, + null, + Boolean.FALSE))); + } + if (ApiUtils.hasDefaultUser(configurationService.apiConfiguration().getUserList())) { + notifs.add(new Notification(Notification.LEVEL.WARNING, + "Default Credentials Need Changing!", + "Your gateway access is configured to use the default username/password combination. This is a security risk. Please ensure you modify your access credentials in your config.xml file.", + null)); + } + return Response.ok(new NotificationList(notifs.build())).build(); + } + + @Override + public @NotNull Response getCapabilities() { + return Response.ok(new CapabilityList(capabilityService.getList() + .getItems() + .stream() + .map(FrontendResourceImpl::fromModel) + .toList())).build(); + } - public @NotNull LinkList getDashboardCTAs() { - final ImmutableList.Builder links = new ImmutableList.Builder().add(new Link("Connect My First Device", + private @NotNull LinkList getDashboardCTAs() { + return new LinkList(List.of(new Link("Connect My First Device", "./protocol-adapters?from=dashboard-cta", LoremIpsum.generate(40), null, @@ -117,35 +158,38 @@ public FrontendResourceImpl( LoremIpsum.generate(40), null, null, - Boolean.FALSE)); - return new LinkList(links.build()); + Boolean.FALSE))); + } + + private @NotNull PreLoginNotice getPreLoginNotice() { + return configurationService.apiConfiguration().getPreLoginNotice(); } - protected @NotNull Link getCloudLink() { + private @NotNull Link getCloudLink() { return hiveMQEdgeRemoteConfigurationService.getConfiguration().getCloudLink(); } - protected @NotNull Link getGitHubLink() { + private @NotNull Link getGitHubLink() { return hiveMQEdgeRemoteConfigurationService.getConfiguration().getGitHubLink(); } - protected @NotNull Link getDocumentationLink() { + private @NotNull Link getDocumentationLink() { return hiveMQEdgeRemoteConfigurationService.getConfiguration().getDocumentationLink(); } - protected @NotNull LinkList getResources() { + private @NotNull LinkList getResources() { return new LinkList(hiveMQEdgeRemoteConfigurationService.getConfiguration().getResources()); } - protected @NotNull ExtensionList getExtensions() { + private @NotNull ExtensionList getExtensions() { return new ExtensionList(modulesAndExtensionsService.getExtensions()); } - protected @NotNull ModuleList getModules() { + private @NotNull ModuleList getModules() { return new ModuleList(modulesAndExtensionsService.getModules()); } - protected @NotNull FirstUseInformation getFirstUse() { + private @NotNull FirstUseInformation getFirstUse() { //-- First use is determined by zero configuration final boolean firstUse = configurationService.bridgeExtractor().getBridges().isEmpty() && protocolAdapterManager.getProtocolAdapters().isEmpty(); @@ -164,54 +208,12 @@ public FrontendResourceImpl( return new FirstUseInformation(firstUse, prefillUsername, prefillPassword, firstUseTitle, firstUseDescription); } - @Override - public @NotNull Response getNotifications() { - final ImmutableList.Builder notifs = new ImmutableList.Builder<>(); - final Optional lastUpdate = configurationService.getLastUpdateTime(); - if (!configurationService.gatewayConfiguration().isMutableConfigurationEnabled() && - configurationService.gatewayConfiguration().isConfigurationExportEnabled() && - lastUpdate.isPresent() && - lastUpdate.get() > System.currentTimeMillis() - (60000 * 5)) { - final Link xmlDownload = - new Link("Download XML Configuration", "/configuration-download", null, null, null, Boolean.FALSE); - notifs.add(new Notification(Notification.LEVEL.NOTICE, - "Configuration Has Changed", - "The gateway configuration has recently been modify. In order to persist these changes across runtimes, please export your configuration for use in your containers.", - xmlDownload)); - } - if (ApiUtils.hasDefaultUser(configurationService.apiConfiguration().getUserList())) { - notifs.add(new Notification(Notification.LEVEL.WARNING, - "Default Credentials Need Changing!", - "Your gateway access is configured to use the default username/password combination. This is a security risk. Please ensure you modify your access credentials in your config.xml file.", - null)); - } - return Response.ok(new NotificationList(notifs.build())).build(); - } - - @Override - public @NotNull Response getCapabilities() { - final List capabilities = capabilityService.getList() - .getItems() - .stream() - .map(cap -> (Capability) Capability.builder() - .id(Capability.IdEnum.fromString(cap.getId())) - .description(cap.getDescription()) - .displayName(cap.getDisplayName()) - .build()).collect(Collectors.toList()); - - return Response.ok(CapabilityList.builder().items(capabilities).build()).build(); - } - - - protected @NotNull EnvironmentProperties getEnvironmentProperties() { - final Map env = new HashMap<>(); - env.put(HiveMQEdgeConstants.VERSION_PROPERTY, systemInformation.getHiveMQVersion()); - env.put(HiveMQEdgeConstants.MUTABLE_CONFIGURAION_ENABLED, - String.valueOf(configurationService.gatewayConfiguration().isMutableConfigurationEnabled())); - env.put(HiveMQEdgeConstants.CONFIGURATION_EXPORT_ENABLED, - String.valueOf(configurationService.gatewayConfiguration().isConfigurationExportEnabled())); - return new EnvironmentProperties(env); + private @NotNull EnvironmentProperties getEnvironmentProperties() { + return new EnvironmentProperties(Map.of(VERSION_PROPERTY, + systemInformation.getHiveMQVersion(), + MUTABLE_CONFIGURAION_ENABLED, + String.valueOf(configurationService.gatewayConfiguration().isMutableConfigurationEnabled()), + CONFIGURATION_EXPORT_ENABLED, + String.valueOf(configurationService.gatewayConfiguration().isConfigurationExportEnabled()))); } - - } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java index ab4c7488dc..1ccd6b2e27 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java @@ -16,21 +16,19 @@ package com.hivemq.configuration.entity.api; import com.hivemq.configuration.entity.EnabledEntity; -import org.jetbrains.annotations.NotNull; - import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlElementRef; import jakarta.xml.bind.annotation.XmlElementRefs; import jakarta.xml.bind.annotation.XmlElementWrapper; import jakarta.xml.bind.annotation.XmlRootElement; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.ArrayList; import java.util.List; import java.util.Objects; -/** - * @author Simon L Johnson - */ @XmlRootElement(name = "admin-api") @XmlAccessorType(XmlAccessType.NONE) @SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"}) @@ -40,48 +38,68 @@ public class AdminApiEntity extends EnabledEntity { @XmlElementRefs({ @XmlElementRef(required = false, type = HttpListenerEntity.class), @XmlElementRef(required = false, type = HttpsListenerEntity.class)}) - private @NotNull List listeners = new ArrayList<>(); + private @NotNull List listeners; @XmlElementRef(required = false) - private @NotNull ApiTlsEntity tls; + private @NotNull ApiJwsEntity jws; + @XmlElementWrapper(name = "users") @XmlElementRef(required = false) - private @NotNull ApiJwsEntity jws = new ApiJwsEntity(); + private @NotNull List users; - @XmlElementWrapper(name = "users") @XmlElementRef(required = false) - private @NotNull List users = new ArrayList<>(); + private @Nullable ApiTlsEntity tls; + + @XmlElementRef(required = false) + private @NotNull PreLoginNoticeEntity preLoginNotice; + + public AdminApiEntity() { + this.listeners = new ArrayList<>(); + this.jws = new ApiJwsEntity(); + this.users = new ArrayList<>(); + this.preLoginNotice = new PreLoginNoticeEntity(); + } public @NotNull List getListeners() { return listeners; } - public ApiJwsEntity getJws() { + public @NotNull ApiJwsEntity getJws() { return jws; } - public List getUsers() { + public @NotNull List getUsers() { return users; } - public ApiTlsEntity getTls() { + public @Nullable ApiTlsEntity getTls() { return tls; } + public @NotNull PreLoginNoticeEntity getPreLoginNotice() { + return preLoginNotice; + } + @Override - public boolean equals(final Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - if (!super.equals(o)) return false; - final AdminApiEntity that = (AdminApiEntity) o; - return Objects.equals(getListeners(), that.getListeners()) && - Objects.equals(getTls(), that.getTls()) && - Objects.equals(getJws(), that.getJws()) && - Objects.equals(getUsers(), that.getUsers()); + public boolean equals(final @Nullable Object o) { + if (this == o) { + return true; + } + if (o instanceof final AdminApiEntity that) { + if (!super.equals(o)) { + return false; + } + return Objects.equals(listeners, that.listeners) && + Objects.equals(tls, that.tls) && + Objects.equals(jws, that.jws) && + Objects.equals(users, that.users) && + Objects.equals(preLoginNotice, that.preLoginNotice); + } + return false; } @Override public int hashCode() { - return Objects.hash(super.hashCode(), getListeners(), getTls(), getJws(), getUsers()); + return Objects.hash(super.hashCode(), listeners, tls, jws, users, preLoginNotice); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java new file mode 100644 index 0000000000..bf4b12c719 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java @@ -0,0 +1,91 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * 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 com.hivemq.configuration.entity.api; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + + +@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"}) +@XmlRootElement(name = "confidentiality-agreement") +@XmlAccessorType(XmlAccessType.NONE) +public class PreLoginNoticeEntity { + + @XmlElement(name = "enabled") + private boolean enabled; + + @XmlElement(name = "title") + private @Nullable String title; + + @XmlElement(name = "message") + private @Nullable String message; + + @XmlElement(name = "message") + private @Nullable String consent; + + public PreLoginNoticeEntity() { + this(false, null, null, null); + } + + public PreLoginNoticeEntity( + final boolean enabled, + final @Nullable String title, + final @Nullable String message, + final @Nullable String consent) { + this.enabled = enabled; + this.title = title; + this.message = message; + this.consent = consent; + } + + public boolean isEnabled() { + return enabled; + } + + public @Nullable String getTitle() { + return title; + } + + public @Nullable String getMessage() { + return message; + } + + public @Nullable String getConsent() { + return consent; + } + + @Override + public boolean equals(final @Nullable Object o) { + if (this == o) { + return true; + } + return o instanceof final PreLoginNoticeEntity that && + enabled == that.enabled && + Objects.equals(title, that.title) && + Objects.equals(message, that.message) && + Objects.equals(consent, that.consent); + } + + @Override + public int hashCode() { + return Objects.hash(enabled, title, message, consent); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java index 2fbf744c1d..3ebad68de5 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java @@ -20,6 +20,7 @@ import com.hivemq.api.config.ApiListener; import com.hivemq.api.config.HttpListener; import com.hivemq.api.config.HttpsListener; +import com.hivemq.api.model.components.PreLoginNotice; import com.hivemq.configuration.entity.HiveMQConfigEntity; import com.hivemq.configuration.entity.api.AdminApiEntity; import com.hivemq.configuration.entity.api.ApiJwsEntity; @@ -27,87 +28,86 @@ import com.hivemq.configuration.entity.api.ApiTlsEntity; import com.hivemq.configuration.entity.api.HttpListenerEntity; import com.hivemq.configuration.entity.api.HttpsListenerEntity; +import com.hivemq.configuration.entity.api.PreLoginNoticeEntity; +import com.hivemq.configuration.entity.api.UserEntity; import com.hivemq.configuration.entity.listener.tls.KeystoreEntity; import com.hivemq.configuration.service.ApiConfigurationService; import com.hivemq.exceptions.UnrecoverableException; import com.hivemq.http.core.UsernamePasswordRoles; +import jakarta.inject.Inject; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; -public class ApiConfigurator implements Configurator{ +import static com.hivemq.http.core.UsernamePasswordRoles.DEFAULT_PASSWORD; +import static com.hivemq.http.core.UsernamePasswordRoles.DEFAULT_USERNAME; - private static final Logger log = LoggerFactory.getLogger(ApiConfigurator.class); +public class ApiConfigurator implements Configurator { - private final @NotNull ApiConfigurationService apiConfigurationService; + private static final @NotNull List DEFAULT_LISTENERS = List.of(new HttpListener(8080, "127.0.0.1")); + private static final @NotNull Logger log = LoggerFactory.getLogger(ApiConfigurator.class); + private static final @NotNull List DEFAULT_USERS = + List.of(new UsernamePasswordRoles(DEFAULT_USERNAME, DEFAULT_PASSWORD, Set.of("ADMIN"))); - private volatile AdminApiEntity configEntity; - private volatile boolean initialized = false; + private final @NotNull ApiConfigurationService apiCfgService; + private volatile @Nullable AdminApiEntity configEntity; @Inject - public ApiConfigurator( - final @NotNull ApiConfigurationService apiConfigurationService) { - this.apiConfigurationService = apiConfigurationService; + public ApiConfigurator(final @NotNull ApiConfigurationService apiCfgService) { + this.apiCfgService = apiCfgService; } - @Override - public boolean needsRestartWithConfig(final HiveMQConfigEntity config) { - if(initialized && hasChanged(this.configEntity, config.getApiConfig())) { - return true; - } - return false; + private static @NotNull UsernamePasswordRoles fromModel(final @NotNull UserEntity userEntity) { + return new UsernamePasswordRoles(userEntity.getUserName(), + userEntity.getPassword(), + Set.copyOf(userEntity.getRoles())); } //-- Converts XML entity types to bean types @Override - public ConfigResult applyConfig(final @NotNull HiveMQConfigEntity config) { - this.configEntity = config.getApiConfig(); - this.initialized = true; - - apiConfigurationService.setEnabled(configEntity.isEnabled()); - - //Users - if (configEntity.getUsers() != null && !configEntity.getUsers().isEmpty()) { - apiConfigurationService.setUserList(configEntity.getUsers() - .stream() - .map(userEntity -> new UsernamePasswordRoles(userEntity.getUserName(), - userEntity.getPassword(), - Set.copyOf(userEntity.getRoles()))) - .collect(Collectors.toList())); - } else { - apiConfigurationService.setUserList(List.of( - new UsernamePasswordRoles(UsernamePasswordRoles.DEFAULT_USERNAME, - UsernamePasswordRoles.DEFAULT_PASSWORD, Set.of("ADMIN")))); - } + public boolean needsRestartWithConfig(final @NotNull HiveMQConfigEntity config) { + final AdminApiEntity entity = configEntity; + return entity != null && hasChanged(entity, config.getApiConfig()); + } + + @Override + public @NotNull ConfigResult applyConfig(final @NotNull HiveMQConfigEntity config) { + final AdminApiEntity entity = config.getApiConfig(); + configEntity = entity; + apiCfgService.setEnabled(entity.isEnabled()); - //JWT - ApiJwsEntity jwsEntity = configEntity.getJws(); - ApiJwtConfiguration.Builder apiJwtConfigurationBuilder = new ApiJwtConfiguration.Builder(); - if (jwsEntity != null) { - apiJwtConfigurationBuilder.withAudience(jwsEntity.getAudience()) - .withIssuer(jwsEntity.getIssuer()) - .withKeySize(jwsEntity.getKeySize()) - .withExpiryTimeMinutes(jwsEntity.getExpiryTimeMinutes()) - .withTokenEarlyEpochThresholdMinutes(jwsEntity.getTokenEarlyEpochThresholdMinutes()); + // Users + final List users = entity.getUsers(); + if (!users.isEmpty()) { + apiCfgService.setUserList(users.stream().map(ApiConfigurator::fromModel).toList()); + } else { + apiCfgService.setUserList(DEFAULT_USERS); } - apiConfigurationService.setApiJwtConfiguration(apiJwtConfigurationBuilder.build()); - if (configEntity.getListeners().isEmpty()) { + // JWT + final ApiJwsEntity jwsEntity = entity.getJws(); + apiCfgService.setApiJwtConfiguration(new ApiJwtConfiguration.Builder().withAudience(jwsEntity.getAudience()) + .withIssuer(jwsEntity.getIssuer()) + .withKeySize(jwsEntity.getKeySize()) + .withExpiryTimeMinutes(jwsEntity.getExpiryTimeMinutes()) + .withTokenEarlyEpochThresholdMinutes(jwsEntity.getTokenEarlyEpochThresholdMinutes()) + .build()); + + if (entity.getListeners().isEmpty()) { //set default listener - apiConfigurationService.setListeners(List.of(new HttpListener(8080, "127.0.0.1"))); + apiCfgService.setListeners(DEFAULT_LISTENERS); } else { - final ImmutableList.Builder builder = ImmutableList.builder(); - for (ApiListenerEntity listener : configEntity.getListeners()) { + final ImmutableList.Builder<@NotNull ApiListener> listenersBld = ImmutableList.builder(); + for (final ApiListenerEntity listener : entity.getListeners()) { if (listener instanceof HttpListenerEntity) { - builder.add(new HttpListener(listener.getPort(), listener.getBindAddress())); + listenersBld.add(new HttpListener(listener.getPort(), listener.getBindAddress())); } else if (listener instanceof HttpsListenerEntity) { final ApiTlsEntity tls = ((HttpsListenerEntity) listener).getTls(); final KeystoreEntity keystoreEntity = tls.getKeystoreEntity(); @@ -115,7 +115,7 @@ public ConfigResult applyConfig(final @NotNull HiveMQConfigEntity config) { log.error("Keystore can not be emtpy for HTTPS listener"); throw new UnrecoverableException(false); } - builder.add(new HttpsListener(listener.getPort(), + listenersBld.add(new HttpsListener(listener.getPort(), listener.getBindAddress(), tls.getProtocols(), tls.getCipherSuites(), @@ -123,13 +123,20 @@ public ConfigResult applyConfig(final @NotNull HiveMQConfigEntity config) { keystoreEntity.getPassword(), keystoreEntity.getPrivateKeyPassword())); } else { - log.error("Unkown API listener type"); + log.error("Unknown API listener type"); throw new UnrecoverableException(false); } } - apiConfigurationService.setListeners(builder.build()); + apiCfgService.setListeners(listenersBld.build()); } + // pre login message + final PreLoginNoticeEntity pln = entity.getPreLoginNotice(); + apiCfgService.setPreLoginNotice(new PreLoginNotice(pln.isEnabled(), + pln.getTitle(), + pln.getMessage(), + pln.getConsent())); + return ConfigResult.SUCCESS; } } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java index 48f200fec4..233790806b 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java @@ -34,7 +34,6 @@ import com.hivemq.exceptions.UnrecoverableException; import com.hivemq.util.ThreadFactoryUtil; import com.hivemq.util.render.EnvVarUtil; -import com.hivemq.util.render.FileFragmentUtil; import com.hivemq.util.render.IfUtil; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBElement; @@ -49,7 +48,6 @@ import org.jetbrains.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.xml.sax.SAXException; import javax.xml.XMLConstants; import javax.xml.transform.stream.StreamSource; @@ -71,188 +69,137 @@ import java.nio.file.attribute.BasicFileAttributeView; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import java.util.stream.Collectors; - +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +import static com.hivemq.util.Files.getFileExtension; +import static com.hivemq.util.Files.getFileNameExcludingExtension; +import static com.hivemq.util.Files.getFilePathExcludingFile; +import static com.hivemq.util.render.FileFragmentUtil.replaceFragmentPlaceHolders; import static java.util.Objects.requireNonNullElse; public class ConfigFileReaderWriter { - private static final Logger log = LoggerFactory.getLogger(ConfigFileReaderWriter.class); - static final String XSD_SCHEMA = "config.xsd"; - public static final String CONFIG_FRAGMENT_PATH = "/fragment/config"; + private static final @NotNull Logger log = LoggerFactory.getLogger(ConfigFileReaderWriter.class); + private static final @NotNull String CONFIG_FRAGMENT_PATH = "/fragment/config"; + private static final @NotNull String XSD_SCHEMA = "config.xsd"; + private static final int MAX_BACK_FILES = 5; + private static final @Nullable Schema CONFIG_XSD; + private static final @NotNull JAXBContext CONFIG_JAXB_CONTEXT; - private final @NotNull ConfigurationFile configurationFile; - protected volatile HiveMQConfigEntity configEntity; - private final Object lock = new Object(); - private boolean defaultBackupConfig = true; - private volatile @Nullable ScheduledExecutorService scheduledExecutorService = null; - private final @NotNull List> configurators; - private final @NotNull Map fragmentToModificationTime = new ConcurrentHashMap<>(); + static { + // load config.xsd + final URL resource = ConfigFileReaderWriter.class.getResource("/" + XSD_SCHEMA); + if (resource != null) { + try { + final URLConnection urlConnection = resource.openConnection(); + urlConnection.setUseCaches(false); + try (final InputStream is = urlConnection.getInputStream()) { + CONFIG_XSD = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI) + .newSchema(new StreamSource(is)); + } + } catch (final Throwable e) { + log.error("Cannot load configuration schema:", e); + throw new UnrecoverableException(false); + } + } else { + log.warn("No schema loaded for validation of config xml."); + CONFIG_XSD = null; + } + // create Jaxb context and marshaller + try { + CONFIG_JAXB_CONTEXT = + JAXBContext.newInstance(ImmutableList.>builder() + .add(HiveMQConfigEntity.class) + // inherited + .add(TCPListenerEntity.class) + .add(WebsocketListenerEntity.class) + .add(TlsTCPListenerEntity.class) + .add(TlsWebsocketListenerEntity.class) + .add(UDPListenerEntity.class) + .add(UDPBroadcastListenerEntity.class) + + .add(FieldMappingEntity.class) + .build() + .toArray(new Class[0])); + } catch (final Throwable e) { + log.error("Cannot create the jaxb context:", e); + throw new UnrecoverableException(false); + } + } + + private final @NotNull ConfigurationFile configFile; + private final @NotNull List> configurators; + private final @NotNull ConcurrentMap fragmentToModificationTime; private final @NotNull BridgeExtractor bridgeExtractor; private final @NotNull ProtocolAdapterExtractor protocolAdapterExtractor; private final @NotNull DataCombiningExtractor dataCombiningExtractor; private final @NotNull UnsExtractor unsExtractor; - private final @NotNull List> reloadableExtractors; - private final @NotNull SystemInformation systemInformation; - - private final @NotNull AtomicLong lastWrite = new AtomicLong(0L); + private final @NotNull List> extractors; + private final @NotNull SystemInformation sysInfo; + private final @NotNull AtomicLong lastWrite; + private final @NotNull AtomicReference configEntity; + private final @NotNull Lock lock; + private final @NotNull AtomicReference executorService; + private boolean defaultBackupConfig; public ConfigFileReaderWriter( - final @NotNull SystemInformation systemInformation, - final @NotNull ConfigurationFile configurationFile, + final @NotNull SystemInformation sysInfo, + final @NotNull ConfigurationFile configFile, final @NotNull List> configurators) { - this.configurationFile = configurationFile; + this.sysInfo = sysInfo; + this.configFile = configFile; this.configurators = configurators; - this.bridgeExtractor = new BridgeExtractor(this); - this.protocolAdapterExtractor = new ProtocolAdapterExtractor(this); - this.dataCombiningExtractor = new DataCombiningExtractor(this); - this.unsExtractor = new UnsExtractor(this); - this.systemInformation = systemInformation; - reloadableExtractors = List.of( - bridgeExtractor, - protocolAdapterExtractor, - dataCombiningExtractor, - unsExtractor); - } - - public HiveMQConfigEntity applyConfig() { - if (configurationFile.file().isEmpty()) { - log.error("No configuration file present. Shutting down HiveMQ Edge."); - throw new UnrecoverableException(false); - } - - final File configFile = configurationFile.file().get(); - final HiveMQConfigEntity hiveMQConfigEntity = readConfigFromXML(configFile); - this.configEntity = hiveMQConfigEntity; - if(!setConfiguration(hiveMQConfigEntity)) { - log.error("Unable to apply the given configuration."); - throw new UnrecoverableException(false); - } - - return hiveMQConfigEntity; - } - - public @NotNull DataCombiningExtractor getDataCombiningExtractor() { - return dataCombiningExtractor; - } - - public @NotNull BridgeExtractor getBridgeExtractor() { - return bridgeExtractor; - } - - public @NotNull ProtocolAdapterExtractor getProtocolAdapterExtractor() { - return protocolAdapterExtractor; - } - - public @NotNull UnsExtractor getUnsExtractor() { - return unsExtractor; + this.extractors = List.of(this.bridgeExtractor = new BridgeExtractor(this), + this.protocolAdapterExtractor = new ProtocolAdapterExtractor(this), + this.dataCombiningExtractor = new DataCombiningExtractor(this), + this.unsExtractor = new UnsExtractor(this)); + this.fragmentToModificationTime = new ConcurrentHashMap<>(); + this.configEntity = new AtomicReference<>(); + this.lastWrite = new AtomicLong(); + this.lock = new ReentrantLock(); + this.executorService = new AtomicReference<>(); + this.defaultBackupConfig = true; } - public void applyConfigAndWatch(final long checkIntervalInMs) { - if(scheduledExecutorService != null) { - throw new IllegalStateException("Config watch was already started"); - } - if (configurationFile.file().isEmpty()) { - log.error("No configuration file present. Shutting down HiveMQ Edge."); - throw new UnrecoverableException(false); - } - - final File configFile = configurationFile.file().get(); - final long interval = (checkIntervalInMs > 0) ? checkIntervalInMs : 0; - log.info("Rereading config file every {} ms", interval); - - final AtomicLong fileModified = new AtomicLong(); - final Map fileModificationTimestamps; - - final HiveMQConfigEntity entity = applyConfig(); - fileModificationTimestamps = findFilesToWatch(entity); - final AtomicLong fileModifiedTimestamp = new AtomicLong(); - try { - fileModifiedTimestamp.set(Files.getLastModifiedTime(configFile.toPath()).toMillis()); - } catch (final IOException e) { - throw new RuntimeException("Unable to read last modified time from " + configFile.getAbsolutePath(), e); - } - - final ThreadFactory threadFactory = ThreadFactoryUtil.create("hivemq-edge-config-watch-%d"); - final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(threadFactory); - scheduledExecutorService.scheduleAtFixedRate( - () -> checkMonitoredFilesForChanges(configFile, fileModified, fileModificationTimestamps) - , 0, interval, TimeUnit.MILLISECONDS); - this.scheduledExecutorService = scheduledExecutorService; - Runtime.getRuntime().addShutdownHook(new Thread(this::stopWatching)); - } - - private void checkMonitoredFilesForChanges( - final File configFile, - final @NotNull AtomicLong fileModified, - final @NotNull Map fileModificationTimestamps) { - try { - final boolean devmode = "true".equals(System.getProperty(HiveMQEdgeConstants.DEVELOPMENT_MODE)); - - if(!devmode) { - final Map pathsToCheck = new HashMap<>(fragmentToModificationTime); - - pathsToCheck.putAll(fileModificationTimestamps); - - pathsToCheck.forEach((key, value) -> { - try { - if (!key.toString().equals(CONFIG_FRAGMENT_PATH) && - Files.getFileAttributeView(key.toRealPath(LinkOption.NOFOLLOW_LINKS), - BasicFileAttributeView.class).readAttributes().lastModifiedTime().toMillis() > - value) { - log.error("Restarting because a required file was updated: {}", key); - System.exit(0); - } - } catch (final IOException e) { - throw new RuntimeException("Unable to read last modified time for " + key, e); - } - }); - } - final long modified; - - if(new File(CONFIG_FRAGMENT_PATH).exists()) { - modified = Files.getLastModifiedTime(new File(CONFIG_FRAGMENT_PATH).toPath()).toMillis(); - } else { - log.warn("No fragment found, checking the full config, only used for testing"); - modified = Files.getLastModifiedTime(configFile.toPath()).toMillis(); - } - if (modified > fileModified.get()) { - fileModified.set(modified); - final HiveMQConfigEntity hiveMQConfigEntity = readConfigFromXML(configFile); - this.configEntity = hiveMQConfigEntity; - if(!setConfiguration(hiveMQConfigEntity)) { - if(!devmode) { - log.error("Restarting because new config can't be hot-reloaded"); - System.exit(0); - } else { - log.error("TESTMODE, NOT RESTARTING"); - } - } - } - } catch (final IOException e) { - throw new RuntimeException(e); + private static @NotNull String toValidationMessage(final @NotNull ValidationEvent event) { + final StringBuilder sb = new StringBuilder(); + final ValidationEventLocator locator = event.getLocator(); + if (locator == null) { + sb.append("\t- XML schema violation caused by: \"").append(event.getMessage()).append("\""); + } else { + sb.append("\t- XML schema violation in line '") + .append(locator.getLineNumber()) + .append("' and column '") + .append(locator.getColumnNumber()) + .append("' caused by: \"") + .append(event.getMessage()) + .append("\""); } + return sb.toString(); } - public static Map findFilesToWatch(final HiveMQConfigEntity entity) { + private static @NotNull Map findFilesToWatch(final @NotNull HiveMQConfigEntity entity) { final Map paths = new ConcurrentHashMap<>(); - entity.getBridgeConfig().forEach(cfg -> { final BridgeTlsEntity tls = cfg.getRemoteBroker().getTls(); - if(tls != null) { - final KeystoreEntity keyStore = cfg.getRemoteBroker().getTls().getKeyStore(); - if(keyStore != null) { + if (tls != null) { + final KeystoreEntity keyStore = tls.getKeyStore(); + if (keyStore != null) { final Path path = Paths.get(keyStore.getPath()); try { paths.put(path, Files.getLastModifiedTime(path).toMillis()); @@ -260,8 +207,8 @@ public static Map findFilesToWatch(final HiveMQConfigEntity entity) throw new RuntimeException(e); } } - final TruststoreEntity trustStore = cfg.getRemoteBroker().getTls().getTrustStore(); - if(trustStore != null) { + final TruststoreEntity trustStore = tls.getTrustStore(); + if (trustStore != null) { final Path path = Paths.get(trustStore.getPath()); try { paths.put(path, Files.getLastModifiedTime(path).toMillis()); @@ -280,19 +227,69 @@ public static Map findFilesToWatch(final HiveMQConfigEntity entity) throw new RuntimeException(e); } } - return paths; } - public void stopWatching() { - final ScheduledExecutorService scheduledExecutorService = this.scheduledExecutorService; - if(scheduledExecutorService != null) { - scheduledExecutorService.shutdownNow(); + private static @NotNull Marshaller createMarshaller() throws JAXBException { + final Marshaller marshaller = CONFIG_JAXB_CONTEXT.createMarshaller(); + if (CONFIG_XSD != null) { + marshaller.setSchema(CONFIG_XSD); + marshaller.setProperty(Marshaller.JAXB_SCHEMA_LOCATION, XSD_SCHEMA); } + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + return marshaller; } - public void writeConfig() { - writeConfigToXML(configurationFile, defaultBackupConfig); + private static @NotNull Unmarshaller createUnmarshaller(final @Nullable List validationErrors) + throws JAXBException { + final Unmarshaller unmarshaller = CONFIG_JAXB_CONTEXT.createUnmarshaller(); + if (CONFIG_XSD != null) { + unmarshaller.setSchema(CONFIG_XSD); + } + if (validationErrors != null) { + unmarshaller.setEventHandler(e -> { + if (e.getSeverity() >= ValidationEvent.ERROR) { + validationErrors.add(e); + } + return true; + }); + } + return unmarshaller; + } + + public @NotNull DataCombiningExtractor getDataCombiningExtractor() { + return dataCombiningExtractor; + } + + public @NotNull BridgeExtractor getBridgeExtractor() { + return bridgeExtractor; + } + + public @NotNull ProtocolAdapterExtractor getProtocolAdapterExtractor() { + return protocolAdapterExtractor; + } + + public @NotNull UnsExtractor getUnsExtractor() { + return unsExtractor; + } + + public void setDefaultBackupConfig(final boolean defaultBackupConfig) { + this.defaultBackupConfig = defaultBackupConfig; + } + + public @NotNull HiveMQConfigEntity applyConfig() { + if (!loadConfigFromXML(getConfigFileOrFail())) { + log.error("Unable to apply the given configuration."); + throw new UnrecoverableException(false); + } + return configEntity.get(); + } + + public void applyConfigAndWatch(final long checkIntervalInMs) { + startWatching(getConfigFileOrFail(), + (checkIntervalInMs > 0) ? checkIntervalInMs : 1000, + this::applyConfig, + this::checkMonitoredFilesForChanges); } public void writeConfigWithSync() { @@ -300,252 +297,171 @@ public void writeConfigWithSync() { log.trace("flushing configuration changes to entity layer"); } try { - syncConfiguration(); - if (configEntity.getGatewayConfig().isMutableConfigurationEnabled()) { - writeConfig(); + // sync config + final HiveMQConfigEntity entity = this.configEntity.get(); + Preconditions.checkNotNull(entity, "Configuration must be loaded to be synchronized"); + configurators.stream() + .filter(Syncable.class::isInstance) + .map(Syncable.class::cast) + .forEach(syncable -> syncable.sync(entity)); + extractors.forEach(extractor -> extractor.sync(entity)); + if (entity.getGatewayConfig().isMutableConfigurationEnabled()) { + writeConfigToXML(); } - } catch (final Exception e){ + } catch (final Exception e) { log.error("Configuration file sync failed: ", e); } finally { lastWrite.set(System.currentTimeMillis()); } } - public @NotNull Long getLastWrite() { + public long getLastWrite() { return lastWrite.get(); } - public void setDefaultBackupConfig(final boolean defaultBackupConfig) { - this.defaultBackupConfig = defaultBackupConfig; - } - - public void writeConfig(final @NotNull ConfigurationFile file, final boolean rollConfig) { - writeConfigToXML(file, rollConfig); - } - - @NotNull Class getConfigEntityClass() { - return HiveMQConfigEntity.class; - } - - @NotNull List> getInheritedEntityClasses() { - return ImmutableList.of( - /* ListenerEntity */ - TCPListenerEntity.class, - WebsocketListenerEntity.class, - TlsTCPListenerEntity.class, - TlsWebsocketListenerEntity.class, - UDPListenerEntity.class, - UDPBroadcastListenerEntity.class); + public void writeConfigToXML(final @NotNull Writer writer) { + lock.lock(); + try { + createMarshaller().marshal(configEntity.get(), writer); + } catch (final Throwable e) { + log.error("Original error message:", e); + throw new UnrecoverableException(false); + } finally { + lock.unlock(); + } } - protected JAXBContext createContext() throws JAXBException { - final Class[] classes = ImmutableList.>builder() - .add(getConfigEntityClass()) - .addAll(getInheritedEntityClasses()) - .add(FieldMappingEntity.class) - .build() - .toArray(new Class[0]); - - return JAXBContext.newInstance(classes); + @VisibleForTesting + void writeConfigToXML() { + writeConfigToXML(getConfigFileOrFail(), defaultBackupConfig); } - private void writeConfigToXML(final @NotNull ConfigurationFile outputFile, final boolean rollConfig) { - - synchronized (lock) { - - //-- Checks need to be inside sync block as could be created by the initialisation - if (configEntity == null) { + @VisibleForTesting + public void writeConfigToXML(final @NotNull File file, final boolean doBackup) { + if (!file.exists() && !file.canWrite()) { + log.error("Unable to write to supplied configuration file {}", file); + throw new UnrecoverableException(false); + } + if (log.isDebugEnabled()) { + log.debug("Writing configuration file {}", file.getAbsolutePath()); + } + lock.lock(); + try { + final HiveMQConfigEntity entity = this.configEntity.get(); + if (entity == null) { log.error("Unable to write uninitialized configuration."); throw new UnrecoverableException(false); } - if (outputFile.file().isEmpty()) { - log.error("No configuration file present."); - throw new UnrecoverableException(false); + backupConfig(file, doBackup); // write the backup of the file before rewriting + try (final FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8)) { + writeConfigToXML(writer); } - if (outputFile.file().get().exists() && !outputFile.file().get().canWrite()) { - log.error("Unable to write to supplied configuration file {}", outputFile.file().get()); - throw new UnrecoverableException(false); - } - - try { - final File configFile = outputFile.file().get(); - log.debug("Writing configuration file {}", configFile.getAbsolutePath()); - //write the backup of the file before rewriting - if (rollConfig) { - backupConfig(configFile, 5); - } - try (final FileWriter fileWriter = new FileWriter(outputFile.file().get(), StandardCharsets.UTF_8)) { - writeConfigToXML(fileWriter); - } - } catch (final IOException e) { - log.error("Error writing file:", e); - throw new UnrecoverableException(false); - } - - } - } - - protected void backupConfig(final @NotNull File configFile, final int maxBackFiles) throws IOException { - int idx = 0; - final String fileNameExclExt = com.hivemq.util.Files.getFileNameExcludingExtension(configFile.getName()); - final String fileExtension = com.hivemq.util.Files.getFileExtension(configFile.getName()); - final String copyPath = com.hivemq.util.Files.getFilePathExcludingFile(configFile.getAbsolutePath()); - - String copyFilename = null; - File copyFile = null; - do { - copyFilename = String.format("%s_%d.%s", fileNameExclExt, ++idx, fileExtension); - copyFile = new File(copyPath, copyFilename); - } while(idx < maxBackFiles && copyFile.exists()); - - if(copyFile.exists()){ - //-- use the oldest available backup index - final File[] backupFiles = new File(copyPath) - .listFiles(child -> - child.isFile() && - child.getName().startsWith(fileNameExclExt) && - child.getName().endsWith(fileExtension)); - Arrays.sort(backupFiles, Comparator.comparingLong(File::lastModified)); - copyFile = backupFiles[0]; - } - if(log.isDebugEnabled()){ - log.debug("Rolling backup of configuration file to {}", copyFile.getName()); + } catch (final IOException e) { + log.error("Error writing file:", e); + throw new UnrecoverableException(false); + } finally { + lock.unlock(); } - FileUtils.copyFile(configFile, copyFile); } - public void writeConfigToXML(final @NotNull Writer writer) { - synchronized (lock) { - try { - final JAXBContext context = createContext(); - final Marshaller marshaller = context.createMarshaller(); - final Schema schema = loadSchema(); - if (schema != null) { - marshaller.setSchema(schema); - marshaller.setProperty(Marshaller.JAXB_SCHEMA_LOCATION, XSD_SCHEMA); - } - marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); - marshaller.marshal(configEntity, writer); - } catch (final JAXBException | IOException | SAXException e) { - log.error("Original error message:", e); - throw new UnrecoverableException(false); - } - } + private @NotNull File getConfigFileOrFail() { + return configFile.file().orElseGet(() -> { + log.error("No configuration file present. Shutting down HiveMQ Edge."); + throw new UnrecoverableException(false); + }); } @VisibleForTesting - public @NotNull HiveMQConfigEntity readConfigFromXML(final @NotNull File configFile) { - + boolean loadConfigFromXML(final @NotNull File configFile) { log.info("Reading configuration file {}", configFile); - final List validationErrors = new ArrayList<>(); - - synchronized (lock) { - try { - final JAXBContext context = createContext(); - final Unmarshaller unmarshaller = context.createUnmarshaller(); - final Schema schema = loadSchema(); - if (schema != null) { - unmarshaller.setSchema(schema); - } - - //replace environment variable placeholders - String configFileContent = Files.readString(configFile.toPath()); - final var fragmentResult = FileFragmentUtil - .replaceFragmentPlaceHolders( - configFileContent, - systemInformation.isConfigFragmentBase64Zip()); - - fragmentToModificationTime.putAll(fragmentResult.getFragmentToModificationTime()); - - configFileContent = fragmentResult.getRenderResult(); //must happen before env rendering so templates can be used with envs - configFileContent = IfUtil.replaceIfPlaceHolders(configFileContent); - configFileContent = EnvVarUtil.replaceEnvironmentVariablePlaceholders(configFileContent); - - try(final ByteArrayInputStream is = - new ByteArrayInputStream(configFileContent.getBytes(StandardCharsets.UTF_8))) { - - final StreamSource streamSource = new StreamSource(is); - - unmarshaller.setEventHandler(e -> { - if (e.getSeverity() >= ValidationEvent.ERROR) { - validationErrors.add(e); - } - return true; - - }); - final JAXBElement result = - unmarshaller.unmarshal(streamSource, getConfigEntityClass()); - - if (!validationErrors.isEmpty()) { - throw new JAXBException("Parsing failed"); - } - - final HiveMQConfigEntity configEntity = result.getValue(); + final List validationErrors = Collections.synchronizedList(new ArrayList<>()); - if (configEntity == null) { - throw new JAXBException("Result is null"); - } - - configEntity.getProtocolAdapterConfig().forEach(e -> e.validate(validationErrors)); + lock.lock(); + try { - configEntity.getDataCombinerEntities().forEach(e -> e.validate(validationErrors)); + // replace environment variable placeholders + String content = Files.readString(configFile.toPath()); + final var fragment = replaceFragmentPlaceHolders(content, sysInfo.isConfigFragmentBase64Zip()); + content = fragment.getRenderResult(); //must happen before env rendering so templates can be used with envs + content = IfUtil.replaceIfPlaceHolders(content); + content = EnvVarUtil.replaceEnvironmentVariablePlaceholders(content); + fragmentToModificationTime.putAll(fragment.getFragmentToModificationTime()); - if (!validationErrors.isEmpty()) { - throw new JAXBException("Parsing failed"); - } - return configEntity; + try (final ByteArrayInputStream is = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) { + final JAXBElement unmarshalled = + createUnmarshaller(validationErrors).unmarshal(new StreamSource(is), HiveMQConfigEntity.class); + if (!validationErrors.isEmpty()) { + throw new JAXBException("Parsing failed"); } - } catch (final JAXBException | IOException e) { - final StringBuilder messageBuilder = new StringBuilder(); - - if (validationErrors.isEmpty()) { - messageBuilder.append("of the following error: "); - messageBuilder.append(requireNonNullElse(e.getCause(), e)); - } else { - messageBuilder.append("of the following errors:"); - for (final ValidationEvent validationError : validationErrors) { - messageBuilder.append(System.lineSeparator()).append(toValidationMessage(validationError)); - } + final HiveMQConfigEntity entity = unmarshalled.getValue(); + if (entity == null) { + throw new JAXBException("Result is null"); + } + entity.getProtocolAdapterConfig().forEach(e -> e.validate(validationErrors)); + entity.getDataCombinerEntities().forEach(e -> e.validate(validationErrors)); + if (!validationErrors.isEmpty()) { + throw new JAXBException("Parsing failed"); } - log.error("Not able to parse configuration file because {}", messageBuilder); - throw new UnrecoverableException(false); - } catch (final Exception e) { - if (e.getCause() instanceof UnrecoverableException) { - if (((UnrecoverableException) e.getCause()).isShowException()) { - log.error("An unrecoverable Exception occurred. Exiting HiveMQ", e); - log.debug("Original error message:", e); - } - System.exit(1); + configEntity.set(entity); + return internalApplyConfig(entity); + } + } catch (final JAXBException | IOException e) { + final StringBuilder sb = new StringBuilder(); + if (validationErrors.isEmpty()) { + sb.append("of the following error: "); + sb.append(requireNonNullElse(e.getCause(), e)); + } else { + sb.append("of the following errors:"); + for (final ValidationEvent validationError : validationErrors) { + sb.append(System.lineSeparator()).append(toValidationMessage(validationError)); } - log.error("Could not read the configuration file {}. Exiting HiveMQ Edge.", - configFile.getAbsolutePath()); + } + log.error("Not able to parse configuration file because {}", sb); + throw new UnrecoverableException(false); + } catch (final Exception e) { + if (e.getCause() instanceof UnrecoverableException) { + if (((UnrecoverableException) e.getCause()).isShowException()) { + log.error("An unrecoverable Exception occurred. Exiting HiveMQ", e); + log.debug("Original error message:", e); + } + System.exit(1); + } + log.error("Could not read the configuration file {}. Exiting HiveMQ Edge.", configFile.getAbsolutePath()); + if (log.isDebugEnabled()) { log.debug("Original error message:", e); - throw new UnrecoverableException(false); } + throw new UnrecoverableException(false); + } finally { + lock.unlock(); } } - boolean setConfiguration(final @NotNull HiveMQConfigEntity config) { - - final List requiresRestart = - configurators.stream() - .filter(c -> c.needsRestartWithConfig(config)) - .map(c -> c.getClass().getSimpleName()) - .collect(Collectors.toList()); - - if (requiresRestart.isEmpty()) { + @VisibleForTesting + boolean internalApplyConfig(final @NotNull HiveMQConfigEntity entity) { + final List requiresRestart = configurators.stream() + .filter(c -> c.needsRestartWithConfig(entity)) + .map(c -> c.getClass().getSimpleName()) + .toList(); + if (!requiresRestart.isEmpty()) { + log.error("Config requires restart because of: {}", requiresRestart); + return false; + } + if (log.isDebugEnabled()) { log.debug("Config can be applied"); + } + + try { for (final Configurator configurator : configurators) { - final @Nullable Configurator.ConfigResult configResult = configurator.applyConfig(config); - if (configResult == null) { + final Configurator.ConfigResult result = configurator.applyConfig(entity); + if (result == null) { log.error("Config {} can not be applied because the result is not found.", configurator.getClass().getSimpleName()); return false; } - switch (configResult) { + switch (result) { case ERROR -> { log.error("Config {} can not be applied because an unrecoverable error is found.", configurator.getClass().getSimpleName()); @@ -558,76 +474,155 @@ boolean setConfiguration(final @NotNull HiveMQConfigEntity config) { } } } - for (final ReloadableExtractor reloadableExtractor : reloadableExtractors) { - final @Nullable Configurator.ConfigResult configResult = reloadableExtractor.updateConfig(config); - if (configResult == null) { + + for (final ReloadableExtractor extractor : extractors) { + final Configurator.ConfigResult result = extractor.updateConfig(entity); + if (result == null) { log.error("Reloadable config {} can not be applied because the result is not found.", - reloadableExtractor.getClass().getSimpleName()); + extractor.getClass().getSimpleName()); return false; } - switch (configResult) { + switch (result) { case ERROR -> { log.error("Reloadable config {} can not be applied because an unrecoverable error is found.", - reloadableExtractor.getClass().getSimpleName()); + extractor.getClass().getSimpleName()); return false; } case NEEDS_RESTART -> { log.error("Reloadable config {} can not be applied because it requires restart.", - reloadableExtractor.getClass().getSimpleName()); + extractor.getClass().getSimpleName()); return false; } } } return true; - } else { - log.error("Config requires restart because of: {}", requiresRestart); + } catch (final Throwable t) { + log.error("An error occurred while applying the configuration.", t); return false; } - } - public void syncConfiguration() { - Preconditions.checkNotNull(configEntity, "Configuration must be loaded to be synchronized"); - configurators.stream() - .filter(c -> c instanceof Syncable) - .forEach(c -> ((Syncable)c).sync(configEntity)); - reloadableExtractors.forEach(reloadableExtractor -> reloadableExtractor.sync(configEntity)); + private void backupConfig(final @NotNull File configFile, final boolean enabled) throws IOException { + if (!enabled) { + return; + } + final String fileNameNoExt = getFileNameExcludingExtension(configFile.getName()); + final String fileExt = getFileExtension(configFile.getName()); + final File copyPath = new File(getFilePathExcludingFile(configFile.getAbsolutePath())); + if (copyPath.exists() && copyPath.isDirectory()) { + int idx = 1; + File copyFile; + do { + final String copyFilename = fileNameNoExt + '_' + (idx++) + (fileExt != null ? "." + fileExt : ""); + copyFile = new File(copyPath, copyFilename); + } while (idx < MAX_BACK_FILES && copyFile.exists()); + + if (copyFile.exists()) { + //-- use the oldest available backup index + final File[] backupFiles = copyPath.listFiles(child -> child.isFile() && + child.getName().startsWith(fileNameNoExt) && + (fileExt == null || child.getName().endsWith(fileExt))); + assert backupFiles != null; + Arrays.sort(backupFiles, Comparator.comparingLong(File::lastModified)); + copyFile = backupFiles[0]; + } + if (log.isDebugEnabled()) { + log.debug("Rolling backup of configuration file to {}", copyFile.getName()); + } + FileUtils.copyFile(configFile, copyFile); + } else { + log.error("Configuration folder {} does not exist or is not a directory", copyPath.getAbsolutePath()); + throw new UnrecoverableException(false); + } } - protected Schema loadSchema() throws IOException, SAXException { - final URL resource = ConfigFileReaderWriter.class.getResource("/" + XSD_SCHEMA); - if (resource != null) { - try (final InputStream is = uncachedStream(resource)) { - final SchemaFactory sf = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); - return sf.newSchema(new StreamSource(is)); + private void startWatching( + final @NotNull File configFile, + final long interval, + final @NotNull Supplier entitySupplier, + final @NotNull ScheduledTask scheduledTask) { + if (executorService.compareAndSet(null, + Executors.newSingleThreadScheduledExecutor(ThreadFactoryUtil.create("hivemq-edge-config-watch-%d")))) { + + final HiveMQConfigEntity entity = entitySupplier.get(); + final Map fileModificationTimestamps = findFilesToWatch(entity); + final AtomicLong fileModified = new AtomicLong(); + try { + fileModified.set(Files.getLastModifiedTime(configFile.toPath()).toMillis()); + } catch (final IOException e) { + throw new RuntimeException("Unable to read last modified time from " + configFile.getAbsolutePath(), e); } + + log.info("Rereading config file every {} ms", interval); + executorService.get() + .scheduleAtFixedRate(() -> scheduledTask.executePeriodicTask(configFile, + fileModified, + fileModificationTimestamps), 0, interval, TimeUnit.MILLISECONDS); + Runtime.getRuntime().addShutdownHook(new Thread(this::stopWatching)); + } else { + throw new IllegalStateException("Config watch was already started"); } - log.warn("No schema loaded for validation of config xml."); - return null; } - private @NotNull InputStream uncachedStream(final @NotNull URL xsd) throws IOException { - final URLConnection urlConnection = xsd.openConnection(); - urlConnection.setUseCaches(false); - return urlConnection.getInputStream(); + private void stopWatching() { + final ScheduledExecutorService es = executorService.getAndSet(null); + if (es != null) { + es.shutdownNow(); + } } - private @NotNull String toValidationMessage(final @NotNull ValidationEvent validationEvent) { - final StringBuilder validationMessageBuilder = new StringBuilder(); - final ValidationEventLocator locator = validationEvent.getLocator(); - if (locator == null) { - validationMessageBuilder.append("\t- XML schema violation caused by: \"") - .append(validationEvent.getMessage()) - .append("\""); - } else { - validationMessageBuilder.append("\t- XML schema violation in line '") - .append(locator.getLineNumber()) - .append("' and column '") - .append(locator.getColumnNumber()) - .append("' caused by: \"") - .append(validationEvent.getMessage()) - .append("\""); + private void checkMonitoredFilesForChanges( + final @NotNull File configFile, + final @NotNull AtomicLong fileModified, + final @NotNull Map fileModificationTimestamps) { + try { + final boolean isDevMode = "true".equals(System.getProperty(HiveMQEdgeConstants.DEVELOPMENT_MODE)); + if (!isDevMode) { + final Map pathsToCheck = new HashMap<>(fragmentToModificationTime); + pathsToCheck.putAll(fileModificationTimestamps); + pathsToCheck.forEach((key, value) -> { + try { + if (!key.toString().equals(CONFIG_FRAGMENT_PATH) && + Files.getFileAttributeView(key.toRealPath(LinkOption.NOFOLLOW_LINKS), + BasicFileAttributeView.class).readAttributes().lastModifiedTime().toMillis() > + value) { + log.error("Restarting because a required file was updated: {}", key); + System.exit(0); + } + } catch (final IOException e) { + throw new RuntimeException("Unable to read last modified time for " + key, e); + } + }); + } + + final long modified; + if (new File(CONFIG_FRAGMENT_PATH).exists()) { + modified = Files.getLastModifiedTime(new File(CONFIG_FRAGMENT_PATH).toPath()).toMillis(); + } else { + log.warn("No fragment found, checking the full config, only used for testing"); + modified = Files.getLastModifiedTime(configFile.toPath()).toMillis(); + } + if (modified > fileModified.get()) { + fileModified.set(modified); + if (!loadConfigFromXML(configFile)) { + if (!isDevMode) { + log.error("Restarting because new config can't be hot-reloaded"); + System.exit(0); + } else { + log.error("TEST MODE, NOT RESTARTING"); + } + } + } + } catch (final IOException e) { + throw new RuntimeException(e); } - return validationMessageBuilder.toString(); + } + + @FunctionalInterface + private interface ScheduledTask { + void executePeriodicTask( + final @NotNull File configFile, + final @NotNull AtomicLong fileModified, + final @NotNull Map fileModificationTimestamps); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java b/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java index e69c22e920..c0d742784f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java @@ -18,9 +18,10 @@ import com.hivemq.api.config.ApiJwtConfiguration; import com.hivemq.api.config.ApiListener; import com.hivemq.api.config.ApiStaticResourcePath; +import com.hivemq.api.model.components.PreLoginNotice; +import com.hivemq.http.core.UsernamePasswordRoles; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import com.hivemq.http.core.UsernamePasswordRoles; import java.util.List; @@ -31,22 +32,25 @@ public interface ApiConfigurationService { @NotNull List getListeners(); + void setListeners(final @NotNull List listeners); + boolean isEnabled(); - @NotNull List getResourcePaths(); + void setEnabled(boolean enabled); - @Nullable ApiJwtConfiguration getApiJwtConfiguration(); + @NotNull List getResourcePaths(); - @NotNull List getUserList(); + void setResourcePaths(final @NotNull List resourcePaths); - void setEnabled(boolean enabled); + @Nullable ApiJwtConfiguration getApiJwtConfiguration(); - void setResourcePaths(@NotNull List resourcePaths); + void setApiJwtConfiguration(final @NotNull ApiJwtConfiguration apiJwtConfiguration); - void setUserList(@NotNull List userList); + @NotNull List getUserList(); - void setListeners(@NotNull List listeners); + void setUserList(final @NotNull List userList); - void setApiJwtConfiguration(@NotNull ApiJwtConfiguration apiJwtConfiguration); + @NotNull PreLoginNotice getPreLoginNotice(); + void setPreLoginNotice(final @NotNull PreLoginNotice preLoginNotice); } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java b/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java index 805193cee3..71ca1c400b 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java @@ -18,18 +18,16 @@ import com.hivemq.api.config.ApiJwtConfiguration; import com.hivemq.api.config.ApiListener; import com.hivemq.api.config.ApiStaticResourcePath; +import com.hivemq.api.model.components.PreLoginNotice; import com.hivemq.configuration.service.ApiConfigurationService; +import com.hivemq.http.core.UsernamePasswordRoles; +import jakarta.inject.Singleton; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import com.hivemq.http.core.UsernamePasswordRoles; -import jakarta.inject.Singleton; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; -/** - * @author Simon L Johnson - */ @Singleton public class ApiConfigurationServiceImpl implements ApiConfigurationService { @@ -38,50 +36,60 @@ public class ApiConfigurationServiceImpl implements ApiConfigurationService { private @NotNull List userList = new CopyOnWriteArrayList<>(); private @NotNull List listeners = new CopyOnWriteArrayList<>(); private @Nullable ApiJwtConfiguration apiJwtConfiguration; + private @NotNull PreLoginNotice preLoginNotice = new PreLoginNotice(); @Override public @NotNull List getListeners() { return listeners; } + public void setListeners(final @NotNull List listeners) { + this.listeners = listeners; + } + @Override public boolean isEnabled() { return enabled; } + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + @Override public @NotNull List getResourcePaths() { return resourcePaths; } + public void setResourcePaths(final @NotNull List resourcePaths) { + this.resourcePaths = resourcePaths; + } + @Override public @Nullable ApiJwtConfiguration getApiJwtConfiguration() { return apiJwtConfiguration; } + public void setApiJwtConfiguration(final @NotNull ApiJwtConfiguration apiJwtConfiguration) { + this.apiJwtConfiguration = apiJwtConfiguration; + } + @Override public @NotNull List getUserList() { return userList; } - - public void setEnabled(final boolean enabled) { - this.enabled = enabled; - } - - public void setResourcePaths(final @NotNull List resourcePaths) { - this.resourcePaths = resourcePaths; - } - public void setUserList(final @NotNull List userList) { this.userList = userList; } - public void setListeners(final @NotNull List listeners) { - this.listeners = listeners; + @Override + public @NotNull PreLoginNotice getPreLoginNotice() { + return preLoginNotice; } - public void setApiJwtConfiguration(final @NotNull ApiJwtConfiguration apiJwtConfiguration) { - this.apiJwtConfiguration = apiJwtConfiguration; + @Override + public void setPreLoginNotice(final @NotNull PreLoginNotice preLoginNotice) { + this.preLoginNotice = preLoginNotice; } } diff --git a/hivemq-edge/src/main/resources/config.xsd b/hivemq-edge/src/main/resources/config.xsd index 72b3918d0f..541776a4aa 100644 --- a/hivemq-edge/src/main/resources/config.xsd +++ b/hivemq-edge/src/main/resources/config.xsd @@ -1049,6 +1049,16 @@ + + + + + + + + + + diff --git a/hivemq-edge/src/test/java/com/hivemq/api/JaxrsResourceTests.java b/hivemq-edge/src/test/java/com/hivemq/api/JaxrsResourceTests.java index 1063acc1f6..6ff4c43e6a 100644 --- a/hivemq-edge/src/test/java/com/hivemq/api/JaxrsResourceTests.java +++ b/hivemq-edge/src/test/java/com/hivemq/api/JaxrsResourceTests.java @@ -44,8 +44,8 @@ public class JaxrsResourceTests { protected final Logger logger = LoggerFactory.getLogger(JaxrsResourceTests.class); static final int TEST_HTTP_PORT = RandomPortGenerator.get(); - static final int CONNECT_TIMEOUT = 1000; - static final int READ_TIMEOUT = 1000; + static final int CONNECT_TIMEOUT = 5000; + static final int READ_TIMEOUT = 5000; static final String HTTP = "http"; static final String JSON_ENTITY = "{\"key\":\"value\"}"; diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderTest.java index 122be8ef67..119d7f0182 100644 --- a/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderTest.java @@ -132,7 +132,7 @@ public void whenUserPropertie_thenMapCorrectlyFilled() throws Exception { assertTrue(userProperties.contains(new MqttUserPropertyEntity("my-name", "my-value2"))); assertTrue(userProperties.contains(new MqttUserPropertyEntity("my-name", "my-value2"))); - configFileReader.writeConfig(); + configFileReader.writeConfigToXML(); final String afterReload = FileUtils.readFileToString(tempFile, UTF_8); assertThat(afterReload).contains("mqttUserProperty"); final @NotNull List config2 = hiveMQConfigEntity.getProtocolAdapterConfig(); diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderWriterTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderWriterTest.java index 50ee6b8da6..8d6113770c 100644 --- a/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderWriterTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ConfigFileReaderWriterTest.java @@ -33,7 +33,7 @@ public void test_alltags() throws Exception{ when(systemInformation.isConfigFragmentBase64Zip()).thenReturn(false); final var reader = new ConfigFileReaderWriter(systemInformation, null, List.of()); final var configFile = new File(getClass().getClassLoader().getResource("configs/testing/alltags.xml").toURI()); - final var configEntity = reader.readConfigFromXML(configFile); + final var configEntity = reader.loadConfigFromXML(configFile); assertThat(configEntity).isNotNull(); } @@ -43,7 +43,7 @@ public void test_empty() throws Exception{ when(systemInformation.isConfigFragmentBase64Zip()).thenReturn(false); final var reader = new ConfigFileReaderWriter(systemInformation, null, List.of()); final var configFile = new File(getClass().getClassLoader().getResource("configs/testing/empty.xml").toURI()); - final var configEntity = reader.readConfigFromXML(configFile); + final var configEntity = reader.loadConfigFromXML(configFile); assertThat(configEntity).isNotNull(); } @@ -53,9 +53,8 @@ public void test_datacombiners_no_source() throws Exception{ when(systemInformation.isConfigFragmentBase64Zip()).thenReturn(false); final var reader = new ConfigFileReaderWriter(systemInformation, null, List.of()); final var configFile = new File(getClass().getClassLoader().getResource("configs/testing/datacombiners_no_source.xml").toURI()); - final var configEntity = reader.readConfigFromXML(configFile); + final var configEntity = reader.loadConfigFromXML(configFile); //This will break as soon as the xsd is fixed assertThat(configEntity).isNotNull(); } - } diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ProtocolAdapterExtractorTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ProtocolAdapterExtractorTest.java index aef406b339..eb386f5136 100644 --- a/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ProtocolAdapterExtractorTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/configuration/reader/ProtocolAdapterExtractorTest.java @@ -390,7 +390,7 @@ public void whenNoMappingsNoTags_setConfigurationShouldReturnTrue() throws IOExc final ProtocolAdapterEntity protocolAdapterEntity = new ProtocolAdapterEntity("adapterId", "protocolId", 1, Map.of(), List.of(), List.of(), List.of()); entity.getProtocolAdapterConfig().add(protocolAdapterEntity); - assertThat(configFileReader.setConfiguration(entity)).isTrue(); + assertThat(configFileReader.internalApplyConfig(entity)).isTrue(); } @Test @@ -406,7 +406,7 @@ public void whenNoMappings_setConfigurationShouldReturnTrue() throws IOException List.of(), List.of(new TagEntity("abc", "def", Map.of()))); entity.getProtocolAdapterConfig().add(protocolAdapterEntity); - assertThat(configFileReader.setConfiguration(entity)).isTrue(); + assertThat(configFileReader.internalApplyConfig(entity)).isTrue(); } @Test @@ -430,7 +430,7 @@ public void whenNoTags_setConfigurationShouldReturnFalse() throws IOException { List.of(), List.of()); entity.getProtocolAdapterConfig().add(protocolAdapterEntity); - assertThat(configFileReader.setConfiguration(entity)).isFalse(); + assertThat(configFileReader.internalApplyConfig(entity)).isFalse(); } @Test @@ -454,7 +454,7 @@ public void whenNorthboundMappingTagNameAreNotFound_setConfigurationShouldReturn List.of(), List.of(new TagEntity("abc", "def", Map.of()))); entity.getProtocolAdapterConfig().add(protocolAdapterEntity); - assertThat(configFileReader.setConfiguration(entity)).isFalse(); + assertThat(configFileReader.internalApplyConfig(entity)).isFalse(); } @ParameterizedTest @@ -482,7 +482,7 @@ public void whenNorthboundMappingTagNameOrTopicIsEmpty_setConfigurationShouldRet List.of(), List.of()); entity.getProtocolAdapterConfig().add(protocolAdapterEntity); - assertThat(configFileReader.setConfiguration(entity)).isFalse(); + assertThat(configFileReader.internalApplyConfig(entity)).isFalse(); } @Test @@ -500,7 +500,7 @@ public void whenSouthboundMappingTagNameIsNotFound_setConfigurationShouldReturnF List.of(southboundMappingEntity), List.of(new TagEntity("abc", "def", Map.of()))); entity.getProtocolAdapterConfig().add(protocolAdapterEntity); - assertThat(configFileReader.setConfiguration(entity)).isFalse(); + assertThat(configFileReader.internalApplyConfig(entity)).isFalse(); } @ParameterizedTest @@ -523,6 +523,6 @@ public void whenSouthboundMappingTagNameOrTopicFilterOrSchemaIsEmpty_setConfigur List.of(southboundMappingEntity), List.of()); entity.getProtocolAdapterConfig().add(protocolAdapterEntity); - assertThat(configFileReader.setConfiguration(entity)).isFalse(); + assertThat(configFileReader.internalApplyConfig(entity)).isFalse(); } } diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java index 317492399d..14081da7a9 100644 --- a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java @@ -45,11 +45,11 @@ public void rewriteUnchangedConfigurationYieldsSameXML() throws IOException, SAX final File tempCopyFile = new File(System.getProperty("java.io.tmpdir"), "copy-config.xml"); tempFile.deleteOnExit(); - configFileReader.writeConfig(new ConfigurationFile(tempCopyFile), false); + configFileReader.writeConfigToXML(new ConfigurationFile(tempCopyFile).file().get(), false); final String copiedFileContent = FileUtils.readFileToString(tempCopyFile, UTF_8); final Diff diff = XMLUnit.compareXML(originalXml, copiedFileContent); - if(!diff.identical()){ + if (!diff.identical()) { System.err.println("xml diff found " + diff); System.err.println(originalXml); System.err.println(copiedFileContent); diff --git a/hivemq-edge/src/test/resources/test-config.xml b/hivemq-edge/src/test/resources/test-config.xml index 41055f64dc..bb9cc78f49 100644 --- a/hivemq-edge/src/test/resources/test-config.xml +++ b/hivemq-edge/src/test/resources/test-config.xml @@ -190,6 +190,9 @@ + + false + From b75ae52ffe0989c2154e22506f9fadba4000bc5b Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 11 Sep 2025 17:49:01 +0200 Subject: [PATCH 2/8] fix misnamed xml entity --- .../hivemq/configuration/entity/api/PreLoginNoticeEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java index bf4b12c719..6354a11bd8 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java @@ -25,7 +25,7 @@ @SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"}) -@XmlRootElement(name = "confidentiality-agreement") +@XmlRootElement(name = "pre-login-notice") @XmlAccessorType(XmlAccessType.NONE) public class PreLoginNoticeEntity { From 8d2ef13fff5c75c3b87f2781f0040a0ad2190cd6 Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 11 Sep 2025 17:53:23 +0200 Subject: [PATCH 3/8] fix misnamed xml entity --- .../hivemq/configuration/entity/api/PreLoginNoticeEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java index 6354a11bd8..bc359a04c7 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/PreLoginNoticeEntity.java @@ -38,7 +38,7 @@ public class PreLoginNoticeEntity { @XmlElement(name = "message") private @Nullable String message; - @XmlElement(name = "message") + @XmlElement(name = "consent") private @Nullable String consent; public PreLoginNoticeEntity() { From f3af6062e5d7a6ec8115efa5a6fe5bf0d5483c50 Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 12 Sep 2025 09:47:44 +0200 Subject: [PATCH 4/8] bug fix --- .../configuration/reader/ConfigFileReaderWriter.java | 6 +++--- .../configuration/writer/AbstractConfigWriterTest.java | 5 +++-- .../configuration/writer/ConfigFileWriterTest.java | 9 +++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java index 233790806b..6444240f3f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ConfigFileReaderWriter.java @@ -333,12 +333,12 @@ public void writeConfigToXML(final @NotNull Writer writer) { @VisibleForTesting void writeConfigToXML() { - writeConfigToXML(getConfigFileOrFail(), defaultBackupConfig); + writeConfigToXML(getConfigFileOrFail(), defaultBackupConfig, true); } @VisibleForTesting - public void writeConfigToXML(final @NotNull File file, final boolean doBackup) { - if (!file.exists() && !file.canWrite()) { + public void writeConfigToXML(final @NotNull File file, final boolean doBackup, final boolean checkExists) { + if (checkExists && !file.exists() && !file.canWrite()) { log.error("Unable to write to supplied configuration file {}", file); throw new UnrecoverableException(false); } diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/AbstractConfigWriterTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/AbstractConfigWriterTest.java index 2d229e538f..68d22d9b96 100644 --- a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/AbstractConfigWriterTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/AbstractConfigWriterTest.java @@ -42,6 +42,7 @@ import java.io.InputStream; import java.util.List; +import static java.util.Objects.requireNonNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -98,8 +99,8 @@ protected ConfigFileReaderWriter createFileReaderWriter(final @NotNull File file return configFileReader; } - protected File loadTestConfigFile() throws IOException { - try (final InputStream is = AbstractConfigWriterTest.class.getResourceAsStream("/test-config.xml")) { + protected @NotNull File loadTestConfigFile() throws IOException { + try (final InputStream is = requireNonNull(AbstractConfigWriterTest.class.getResourceAsStream("/test-config.xml"))) { final File tempFile = new File(System.getProperty("java.io.tmpdir"), "original-config.xml"); tempFile.deleteOnExit(); FileUtils.copyInputStreamToFile(is, tempFile); diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java index 14081da7a9..bf1e059364 100644 --- a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java @@ -17,10 +17,12 @@ package com.hivemq.configuration.writer; import com.hivemq.configuration.entity.HiveMQConfigEntity; +import com.hivemq.configuration.entity.api.PreLoginNoticeEntity; import com.hivemq.configuration.reader.ConfigFileReaderWriter; import com.hivemq.configuration.reader.ConfigurationFile; import org.apache.commons.io.FileUtils; import org.junit.Assert; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.xml.sax.SAXException; import wiremock.org.custommonkey.xmlunit.Diff; @@ -41,11 +43,14 @@ public void rewriteUnchangedConfigurationYieldsSameXML() throws IOException, SAX final String originalXml = FileUtils.readFileToString(tempFile, UTF_8); final ConfigFileReaderWriter configFileReader = createFileReaderWriter(tempFile); - final HiveMQConfigEntity hiveMQConfigEntity = configFileReader.applyConfig(); + final HiveMQConfigEntity entity = configFileReader.applyConfig(); + + final PreLoginNoticeEntity notice = entity.getApiConfig().getPreLoginNotice(); + Assertions.assertNotNull(notice); final File tempCopyFile = new File(System.getProperty("java.io.tmpdir"), "copy-config.xml"); tempFile.deleteOnExit(); - configFileReader.writeConfigToXML(new ConfigurationFile(tempCopyFile).file().get(), false); + configFileReader.writeConfigToXML(new ConfigurationFile(tempCopyFile).file().get(), false, false); final String copiedFileContent = FileUtils.readFileToString(tempCopyFile, UTF_8); final Diff diff = XMLUnit.compareXML(originalXml, copiedFileContent); From 17db8bb000ad75716c0efefb5dbc135c32ba03b2 Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 12 Sep 2025 12:56:17 +0200 Subject: [PATCH 5/8] fix tests --- .../api/model/components/PreLoginNotice.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java index a7fdc0ae5e..ddbccb03c9 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java @@ -31,11 +31,11 @@ public class PreLoginNotice { @JsonProperty("title") @Schema(description = "The title of the notice") - private final @Nullable String title; + private final @NotNull String title; @JsonProperty("message") @Schema(description = "The message of the notice") - private final @Nullable String message; + private final @NotNull String message; @JsonProperty("consent") @Schema(description = "The message of the notice") @@ -52,11 +52,8 @@ public PreLoginNotice( final @Nullable String message, final @Nullable String consent) { this.enabled = requireNonNullElse(enabled, false); - if (this.enabled && (title == null || title.isEmpty() || message == null || message.isEmpty())) { - throw new IllegalArgumentException("Title and message cannot be null or empty when enabled"); - } - this.title = title; - this.message = message; + this.title = requireNonNullElse(title, ""); + this.message = requireNonNullElse(message, ""); this.consent = consent; } @@ -64,11 +61,11 @@ public boolean getEnabled() { return enabled; } - public @Nullable String getTitle() { + public @NotNull String getTitle() { return title; } - public @Nullable String getMessage() { + public @NotNull String getMessage() { return message; } From 071e5d879f51f54a686c80c60827b2afd6130c0a Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 12 Sep 2025 14:39:59 +0200 Subject: [PATCH 6/8] knit --- .../java/com/hivemq/api/model/components/PreLoginNotice.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java index ddbccb03c9..609a6629ab 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java @@ -57,7 +57,7 @@ public PreLoginNotice( this.consent = consent; } - public boolean getEnabled() { + public boolean isEnabled() { return enabled; } From f43526253ed6f0d6ddb00387cb51b437cb3bd784 Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 12 Sep 2025 15:00:55 +0200 Subject: [PATCH 7/8] revert, jackson needs getEnabled, not isEnabled --- .../java/com/hivemq/api/model/components/PreLoginNotice.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java index 609a6629ab..ddbccb03c9 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java @@ -57,7 +57,7 @@ public PreLoginNotice( this.consent = consent; } - public boolean isEnabled() { + public boolean getEnabled() { return enabled; } From 05f31bee5dd12eb96ad3de916f01df88042fb749 Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 12 Sep 2025 15:38:50 +0200 Subject: [PATCH 8/8] trucku truck chast chast what a waste of brain lol --- .../com/hivemq/api/model/components/PreLoginNotice.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java index ddbccb03c9..49c464d65b 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/components/PreLoginNotice.java @@ -27,7 +27,7 @@ public class PreLoginNotice { @JsonProperty("enabled") @Schema(description = "Whether the pre login notice should be shown prior to login in") - private final @NotNull Boolean enabled; + private final @NotNull boolean enabled; @JsonProperty("title") @Schema(description = "The title of the notice") @@ -47,7 +47,7 @@ public PreLoginNotice() { @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) public PreLoginNotice( - final @Nullable Boolean enabled, + final @Nullable boolean enabled, final @Nullable String title, final @Nullable String message, final @Nullable String consent) { @@ -57,7 +57,7 @@ public PreLoginNotice( this.consent = consent; } - public boolean getEnabled() { + public boolean isEnabled() { return enabled; }