diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java index 6fb7cc4f9e..7d912213be 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java @@ -9,9 +9,12 @@ */ package org.eclipse.hawkbit.security; +import java.time.Duration; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -124,6 +127,7 @@ public static class Dos { private final Filter filter = new Filter(); private final Filter uiFilter = new Filter(); + private final ControllerAttributes controllerAttributes = new ControllerAttributes(); /** * Maximum number of status updates that the controller can report for * an action (0 to disable). @@ -224,5 +228,39 @@ public static class Filter { */ private int maxWrite = 50; } + + /** + * Throttling of not-requested (device-initiated without actual update) controller attribute updates. + */ + @Data + public static class ControllerAttributes { + + /** + * Default minimum interval between accepted device-initiated attribute + * updates. ZERO (default) disables throttling for all tenants. + */ + private Duration minUpdateInterval = Duration.ZERO; + + /** + * Per-tenant overrides of {@link #minUpdateInterval}, keyed by tenant name + * (case-insensitive). Tenants without an entry use {@link #minUpdateInterval}. + */ + private Map perTenant = new HashMap<>(); + + /** + * @param tenant current tenant (may be {@code null}) + * @return the configured minimum interval for the tenant, or the default + */ + public Duration intervalFor(final String tenant) { + if (tenant != null) { + for (final Map.Entry entry : perTenant.entrySet()) { + if (entry.getKey().equalsIgnoreCase(tenant)) { + return entry.getValue(); + } + } + } + return minUpdateInterval; + } + } } } \ No newline at end of file diff --git a/hawkbit-core/src/test/java/org/eclipse/hawkbit/security/HawkbitSecurityPropertiesTest.java b/hawkbit-core/src/test/java/org/eclipse/hawkbit/security/HawkbitSecurityPropertiesTest.java new file mode 100644 index 0000000000..280cf4f09e --- /dev/null +++ b/hawkbit-core/src/test/java/org/eclipse/hawkbit/security/HawkbitSecurityPropertiesTest.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.eclipse.hawkbit.security.HawkbitSecurityProperties.Dos.ControllerAttributes; +import org.junit.jupiter.api.Test; + +class HawkbitSecurityPropertiesTest { + + @Test + void intervalForDefaultsToZeroWhenNothingConfigured() { + final ControllerAttributes props = new ControllerAttributes(); + assertThat(props.intervalFor("TENANT")).isEqualTo(Duration.ZERO); + assertThat(props.intervalFor(null)).isEqualTo(Duration.ZERO); + } + + @Test + void intervalForFallsBackToDefaultWhenTenantNotListed() { + final ControllerAttributes props = new ControllerAttributes(); + props.setMinUpdateInterval(Duration.ofMinutes(2)); + assertThat(props.intervalFor("UNLISTED")).isEqualTo(Duration.ofMinutes(2)); + } + + @Test + void intervalForResolvesPerTenantOverrideCaseInsensitively() { + final ControllerAttributes props = new ControllerAttributes(); + props.setMinUpdateInterval(Duration.ofMinutes(2)); + props.getPerTenant().put("Abusive", Duration.ofMinutes(5)); + assertThat(props.intervalFor("ABUSIVE")).isEqualTo(Duration.ofMinutes(5)); + assertThat(props.intervalFor("abusive")).isEqualTo(Duration.ofMinutes(5)); + assertThat(props.intervalFor("OTHER")).isEqualTo(Duration.ofMinutes(2)); + } +} diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java index 6d4d3c5177..17ab374d49 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java @@ -44,6 +44,7 @@ "controllerId" : "137", "updateStatus" : "in_sync", "lastControllerRequestAt" : 1682408577978, + "lastControllerAttributesUpdate" : 1682408577998, "installedAt" : 1682408577987, "ipAddress" : "192.168.0.1", "address" : "http://192.168.0.1", @@ -115,6 +116,9 @@ public class MgmtTarget extends MgmtNamedEntity { @Schema(description = "Timestamp of the last controller request", example = "1691065941102") private Long lastControllerRequestAt; + @Schema(description = "Timestamp of the last controller attributes (config data) update", example = "1691065941102") + private Long lastControllerAttributesUpdate; + @Schema(description = "Install timestamp", example = "1691065941155") private Long installedAt; diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java index 972950fd66..72bb853858 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java @@ -168,6 +168,10 @@ public static MgmtTarget toResponse(final Target target, final Function confirmationOptions() { return Stream.of(Arguments.of(true, true), Arguments.of(true, false), Arguments.of(false, true), Arguments.of(false, false), Arguments.of(true, null), Arguments.of(false, null)); diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java index 85f7e02575..b09fa9eda8 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java @@ -78,6 +78,11 @@ public interface Target extends NamedEntity, Identifiable { */ Long getLastTargetQuery(); + /** + * @return timestamp (epoch millis) of the last accepted controller attributes update, or {@code null} if never updated + */ + Long getLastControllerAttributesUpdate(); + /** * @return time in {@link TimeUnit#MILLISECONDS} GMT when {@link #getInstalledDistributionSet()} was applied. */ diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_20_5__target_controller_attributes_update__H2.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_20_5__target_controller_attributes_update__H2.sql new file mode 100644 index 0000000000..62747faecb --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_20_5__target_controller_attributes_update__H2.sql @@ -0,0 +1,2 @@ +ALTER TABLE sp_target ADD COLUMN last_controller_attributes_update BIGINT; +ALTER TABLE sp_target ADD COLUMN last_controller_attributes_update_requested BOOLEAN; diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_20_5__target_controller_attributes_update__MYSQL.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_20_5__target_controller_attributes_update__MYSQL.sql new file mode 100644 index 0000000000..8b731385ae --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_20_5__target_controller_attributes_update__MYSQL.sql @@ -0,0 +1,3 @@ +ALTER TABLE sp_target + ADD COLUMN last_controller_attributes_update BIGINT, + ADD COLUMN last_controller_attributes_update_requested BOOLEAN; diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_20_5__target_controller_attributes_update__POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_20_5__target_controller_attributes_update__POSTGRESQL.sql new file mode 100644 index 0000000000..8b731385ae --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_20_5__target_controller_attributes_update__POSTGRESQL.sql @@ -0,0 +1,3 @@ +ALTER TABLE sp_target + ADD COLUMN last_controller_attributes_update BIGINT, + ADD COLUMN last_controller_attributes_update_requested BOOLEAN; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java index d6b57f14c7..3c8c77587f 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java @@ -48,6 +48,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.ListUtils; import org.eclipse.hawkbit.context.AccessContext; +import org.eclipse.hawkbit.security.HawkbitSecurityProperties; import org.eclipse.hawkbit.ql.jpa.QLSupport; import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; @@ -146,6 +147,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont private final ControllerPollProperties controllerPollProperties; private final PlatformTransactionManager txManager; private final EntityManager entityManager; + private final HawkbitSecurityProperties securityProperties; private final Duration minPollingTime; private final Duration maxPollingTime; @@ -162,6 +164,7 @@ protected JpaControllerManagement( final DistributionSetManagement distributionSetManagement, final ControllerPollProperties controllerPollProperties, final PlatformTransactionManager txManager, final EntityManager entityManager, + final HawkbitSecurityProperties securityProperties, final ScheduledExecutorService executorService) { super(actionRepository, actionStatusRepository, quotaManagement, repositoryProperties); @@ -175,6 +178,7 @@ protected JpaControllerManagement( this.controllerPollProperties = controllerPollProperties; this.txManager = txManager; this.entityManager = entityManager; + this.securityProperties = securityProperties; minPollingTime = controllerPollProperties.getMinPollingTime() == null ? Duration.of(0, ChronoUnit.SECONDS) @@ -442,6 +446,19 @@ public Target updateControllerAttributes(final String controllerId, final Map writeAttributes(controllerId, 1, "a", "v1")); + + // force it requested again (admin context) and update once more, still accepted + JpaTarget target = (JpaTarget) targetManagement.getByControllerId(controllerId); + target.setRequestControllerAttributes(true); + targetRepository.save(target); + runAs(controller, () -> writeAttributes(controllerId, 1, "b", "v2")); + + target = (JpaTarget) targetManagement.getByControllerId(controllerId); + assertThat(targetManagement.getControllerAttributes(controllerId)).containsKeys("a0", "b0"); + assertThat(target.getLastControllerAttributesUpdateRequested()).isTrue(); + assertThat(target.getLastControllerAttributesUpdate()).isNotNull(); + } + + /** + * Two consecutive device-initiated updates within the interval: the second is dropped + * (no write) and returns silently. + */ + @Test + void deviceInitiatedUpdateWithinIntervalIsDropped() { + final String controllerId = "throttled"; + testdataFactory.createTarget(controllerId); + securityProperties.getDos().getControllerAttributes().setMinUpdateInterval(Duration.ofHours(1)); + + final WithUser controller = SecurityContextSwitch.withController("controller"); + runAs(controller, () -> { + // accepted - initially requestAttribute is true, internally requestAttribute is set to false, lastControllerAttributesUpdateRequested is set to true, lastControllerAttributesUpdate is set to now + writeAttributes(controllerId, 1, "initial", "v"); + // accepted - requestAttribute is false, but lastControllerAttributesUpdateRequested is true - i.e. this update is first to be initated from device -> lastControllerAttributesUpdateRequested is set to false, lastControllerAttributesUpdate is ste to now + writeAttributes(controllerId, 1, "first-device-initiated", "v1"); + // rejected - requestAttribute is false and lastControllerAttributesUpdateRequested is false and timeout has not passed + writeAttributes(controllerId, 1, "second-device-initiated", "v2"); + }); + + assertThat(targetManagement.getControllerAttributes(controllerId)) + .containsKeys("initial0", "first-device-initiated0") + .doesNotContainKey("second-device-initiated0"); + } + + /** + * A device-initiated update after the interval has elapsed is accepted. + */ + @Test + void deviceInitiatedUpdateAfterIntervalIsAccepted() { + final String controllerId = "elapsed"; + testdataFactory.createTarget(controllerId); + securityProperties.getDos().getControllerAttributes().setMinUpdateInterval(Duration.ofHours(1)); + + final WithUser controller = SecurityContextSwitch.withController("controller"); + runAs(controller, () -> { + writeAttributes(controllerId, 1, "initial", "v"); // accepted - initialize requestAttribute is true for new devices + writeAttributes(controllerId, 1, "first-device-initiated", "v1"); // device-initiated, accepted, stamps now + }); + + // backdate the stamp beyond the interval + final JpaTarget backdated = (JpaTarget) targetManagement.getByControllerId(controllerId); + backdated.setLastControllerAttributesUpdate(System.currentTimeMillis() - Duration.ofHours(2).toMillis()); + targetRepository.save(backdated); + + runAs(controller, () -> writeAttributes(controllerId, 1, "second-device-initiated0", "v2")); // device-initiated, elapsed -> accepted + + assertThat(targetManagement.getControllerAttributes(controllerId)) + .containsKeys("initial0", "first-device-initiated0", "second-device-initiated0"); + } + + /** + * With throttling disabled (default 0), rapid device-initiated updates are all accepted. + */ + @Test + void deviceInitiatedUpdatesNotThrottledWhenDisabled() { + final String controllerId = "disabled"; + testdataFactory.createTarget(controllerId); + // minUpdateInterval left at ZERO (default) + + final WithUser controller = SecurityContextSwitch.withController("controller"); + runAs(controller, () -> { + writeAttributes(controllerId, 1, "initial", "v"); + writeAttributes(controllerId, 1, "first-device-initiated", "v1"); + writeAttributes(controllerId, 1, "second-device-initiated", "v2"); + }); + + assertThat(targetManagement.getControllerAttributes(controllerId)) + .containsKeys("initial0", "first-device-initiated0", "second-device-initiated0"); + } }