diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java index 6264efb50..9f12b7283 100644 --- a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java +++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java @@ -19,13 +19,13 @@ import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetRestApi; import org.eclipse.hawkbit.mgmt.rest.api.MgmtTenantManagementRestApi; import org.eclipse.hawkbit.sdk.HawkbitClient; import org.eclipse.hawkbit.sdk.Tenant; -import org.eclipse.hawkbit.sdk.device.DdiController; import org.springframework.util.ObjectUtils; import java.security.SecureRandom; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Random; /** @@ -51,25 +51,25 @@ public class SetupHelper { final MgmtTenantManagementRestApi mgmtTenantManagementRestApi = hawkbitClient.mgmtService(MgmtTenantManagementRestApi.class, tenant); if (ObjectUtils.isEmpty(tenant.getGatewayToken())) { - if (!((Boolean)mgmtTenantManagementRestApi + if (!((Boolean) Objects.requireNonNull(mgmtTenantManagementRestApi .getTenantConfigurationValue(AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED) - .getBody().getValue())) { + .getBody()).getValue())) { mgmtTenantManagementRestApi.updateTenantConfiguration( Map.of(AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED, true) ); } } else { - if (!((Boolean)mgmtTenantManagementRestApi + if (!((Boolean) Objects.requireNonNull(mgmtTenantManagementRestApi .getTenantConfigurationValue(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED) - .getBody().getValue())) { + .getBody()).getValue())) { mgmtTenantManagementRestApi.updateTenantConfiguration( Map.of(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED, true) ); } if (!tenant.getGatewayToken().equals( - mgmtTenantManagementRestApi + Objects.requireNonNull(mgmtTenantManagementRestApi .getTenantConfigurationValue(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY) - .getBody().getValue())) { + .getBody()).getValue())) { mgmtTenantManagementRestApi.updateTenantConfiguration( Map.of(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY, tenant.getGatewayToken()) ); @@ -85,7 +85,7 @@ public class SetupHelper { final MgmtTargetRestApi mgmtTargetRestApi = hawkbitClient.mgmtService(MgmtTargetRestApi.class, tenant); try { // test if target exist, if not - throws 404 - final MgmtTarget target = mgmtTargetRestApi.getTarget(controllerId).getBody(); + final MgmtTarget target = Objects.requireNonNull(mgmtTargetRestApi.getTarget(controllerId).getBody()); if (ObjectUtils.isEmpty(securityTargetToken)) { if (ObjectUtils.isEmpty(target.getSecurityToken())) { // generate random to set to tha existing target without configured security token diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java index a8c9b7cd9..c87bb831f 100644 --- a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java +++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java @@ -21,6 +21,7 @@ import org.eclipse.hawkbit.sdk.HawkbitSDKConfigurtion; import org.eclipse.hawkbit.sdk.Tenant; import org.eclipse.hawkbit.sdk.demo.SetupHelper; import org.eclipse.hawkbit.sdk.device.DdiController; +import org.eclipse.hawkbit.sdk.device.UpdateHandler; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -30,6 +31,7 @@ import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; import org.springframework.util.ObjectUtils; +import java.util.Optional; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -56,7 +58,9 @@ public class DeviceApp { DdiController device( @Value("${hawkbit.device:controller-default}") final String controllerId, @Value("${hawkbit.device.securityToken:}") final String securityToken, - final Tenant defaultTenant, final HawkbitClient hawkbitClient) { + final Tenant defaultTenant, + final Optional updateHandler, + final HawkbitClient hawkbitClient) { return new DdiController( defaultTenant, Controller.builder() @@ -65,6 +69,7 @@ public class DeviceApp { (ObjectUtils.isEmpty(defaultTenant.getGatewayToken()) ? SetupHelper.randomToken() : securityToken) : securityToken) .build(), + updateHandler.orElse(null), hawkbitClient).setOverridePollMillis(10_000); } diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java index 6cf092e9c..24f33d45c 100644 --- a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java +++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java @@ -21,6 +21,7 @@ import org.eclipse.hawkbit.sdk.HawkbitSDKConfigurtion; import org.eclipse.hawkbit.sdk.Tenant; import org.eclipse.hawkbit.sdk.demo.SetupHelper; import org.eclipse.hawkbit.sdk.device.DdiController; +import org.eclipse.hawkbit.sdk.device.UpdateHandler; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -30,6 +31,7 @@ import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.ShellOption; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -57,6 +59,7 @@ public class MultiDeviceApp { public static class Shell { private final Tenant tenant; + private final UpdateHandler updateHandler; private final HawkbitClient hawkbitClient; private final Map devices = new ConcurrentHashMap<>(); @@ -64,8 +67,9 @@ public class MultiDeviceApp { private boolean setup; - Shell(final Tenant tenant, final HawkbitClient hawkbitClient) { + Shell(final Tenant tenant, final Optional updateHandler, final HawkbitClient hawkbitClient) { this.tenant = tenant; + this.updateHandler = updateHandler.orElse(null); this.hawkbitClient = hawkbitClient; } @@ -86,11 +90,13 @@ public class MultiDeviceApp { securityTargetToken = null; } if (device == null) { - device = new DdiController(tenant, + device = new DdiController( + tenant, Controller.builder() .controllerId(controllerId) .securityToken(securityTargetToken) .build(), + updateHandler, hawkbitClient).setOverridePollMillis(10_000); final DdiController oldDevice = devices.putIfAbsent(controllerId, device); if (oldDevice != null) { diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/ArtifactHandler.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/ArtifactHandler.java new file mode 100644 index 000000000..d57a31cc1 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/ArtifactHandler.java @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2024 Contributors to the Eclipse Foundation + * + * 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.sdk.device; + +import java.nio.file.Path; +import java.util.Optional; + +/** + * Artifact handler provide plug-in endpoint allowing for customization of the artifact processing. + * For instance, it could save downloaded files in some location and on successful finish could + * apply them. + */ +public interface ArtifactHandler { + + ArtifactHandler SKIP = url -> DownloadHandler.SKIP; + + DownloadHandler getDownloadHandler(final String url); + + interface DownloadHandler { + + enum Status { + SUCCESS, ERROR + } + + DownloadHandler SKIP = new DownloadHandler() { + @Override + public void read(byte[] buff, int off, int len) { + // skip + } + + @Override + public void finished(Status status) { + // skip + } + + @Override + public Optional download() { + return Optional.empty(); + } + }; + + /** + * Called on every read chunk of data + * + * @param buff buffer + * @param off offset + * @param len read bytes + */ + void read(byte[] buff, int off, int len); + + /** + * Called when the download has finished. It could have finished with error in case of network problems, + * invalid hashes or size. In case of success the hashes and size are already checked and valid. + * + * @param status finish status. On error the download shall be discarded and related resources shall be released + */ + void finished(Status status); + + /** + * Return download path if existing + * + * @return the path to the download + */ + Optional download(); + } +} diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java index 57aa793b7..c7b5a1a90 100644 --- a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java +++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java @@ -9,43 +9,21 @@ */ package org.eclipse.hawkbit.sdk.device; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.security.DigestOutputStream; -import java.security.KeyManagementException; -import java.security.KeyStoreException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.time.LocalTime; import java.time.temporal.ChronoField; import java.util.AbstractMap; -import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import com.google.common.io.BaseEncoding; -import com.google.common.io.ByteStreams; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.core5.ssl.SSLContextBuilder; -import org.eclipse.hawkbit.ddi.json.model.DdiArtifact; import org.eclipse.hawkbit.ddi.json.model.DdiChunk; import org.eclipse.hawkbit.ddi.json.model.DdiConfigData; import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback; @@ -58,14 +36,11 @@ import org.eclipse.hawkbit.sdk.Controller; import org.eclipse.hawkbit.sdk.HawkbitClient; import org.eclipse.hawkbit.sdk.Tenant; import org.springframework.hateoas.Link; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; /** - * Abstract class representing DDI device connecting directly to hawkVit. + * Class representing DDI device connecting directly to hawkBit. */ @Slf4j @Getter @@ -82,6 +57,7 @@ public class DdiController { private final String tenantId; private final String controllerId; + private final UpdateHandler updateHandler; private final DdiRootControllerRestApi ddiApi; // configuration @@ -95,22 +71,23 @@ public class DdiController { // state private volatile ScheduledExecutorService executorService; private volatile Long currentActionId; - private volatile UpdateStatus updateStatus; /** * Creates a new device instance. * * @param tenant the tenant of the device belongs to * @param controller the the controller - * @param hawkbitClient a factory for creaint to {@link DdiRootControllerRestApi} (and moreused) + * @param hawkbitClient a factory for creating to {@link DdiRootControllerRestApi} (and used) * for communication to hawkBit */ - public DdiController(final Tenant tenant, final Controller controller, final HawkbitClient hawkbitClient) { + public DdiController(final Tenant tenant, final Controller controller, + final UpdateHandler updateHandler, final HawkbitClient hawkbitClient) { this.tenantId = tenant.getTenantId(); gatewayToken = tenant.getGatewayToken(); downloadAuthenticationEnabled = tenant.isDownloadAuthenticationEnabled(); this.controllerId = controller.getControllerId(); this.targetSecurityToken = controller.getSecurityToken(); + this.updateHandler = updateHandler == null ? UpdateHandler.SKIP : updateHandler; ddiApi = hawkbitClient.ddiService(DdiRootControllerRestApi.class, tenant, controller); } @@ -128,7 +105,7 @@ public class DdiController { } private void poll() { - Optional.ofNullable(executorService).ifPresent(executor -> { + Optional.ofNullable(executorService).ifPresent(executor -> getControllerBase().ifPresentOrElse( controllerBase -> { final Optional confirmationBaseLink = getRequiredLink(controllerBase, CONFIRMATION_BASE_LINK); @@ -150,7 +127,8 @@ public class DdiController { final List modules = deployment.getChunks(); currentActionId = actionId; - executor.submit(new UpdateProcessor(actionId, updateType, modules)); + executor.submit( + updateHandler.getUpdateProcessor(this, updateType, modules)); } else if (currentActionId != actionId) { // TODO - cancel and start new one? log.info(LOG_PREFIX + "Action {} is canceled while in process!", getTenantId(), @@ -170,8 +148,7 @@ public class DdiController { // error has occurred or no controller base hasn't been acquired executor.schedule(this::poll, DEFAULT_POLL_MS, TimeUnit.MILLISECONDS); } - ); - }); + )); } private Optional getControllerBase() { @@ -238,9 +215,13 @@ public class DdiController { getDdiApi().putConfigData(configData, getTenantId(), getControllerId()); } - private void sendFeedback(final long actionId) { - getDdiApi().postDeploymentBaseActionFeedback(updateStatus.feedback(), getTenantId(), getControllerId(), actionId); - currentActionId = null; + void sendFeedback(final UpdateStatus updateStatus) { + getDdiApi().postDeploymentBaseActionFeedback( + updateStatus.feedback(), getTenantId(), getControllerId(), currentActionId); + if (updateStatus.status() == UpdateStatus.Status.SUCCESSFUL || + updateStatus.status() == UpdateStatus.Status.ERROR) { + currentActionId = null; + } } private void sendConfirmationFeedback(final long actionId) { @@ -254,239 +235,4 @@ public class DdiController { final String href = link.getHref(); return Long.parseLong(href.substring(href.lastIndexOf('/') + 1, href.indexOf('?'))); } - - private class UpdateProcessor implements Runnable { - - private static final String BUT_GOT_LOG_MESSAGE = " but got: "; - private static final String DOWNLOAD_LOG_MESSAGE = "Download "; - private static final int MINIMUM_TOKENLENGTH_FOR_HINT = 6; - - private final long actionId; - private final DdiDeployment.HandlingType updateType; - private final List modules; - - private UpdateProcessor( - final long actionId, final DdiDeployment.HandlingType updateType, final List modules) { - this.actionId = actionId; - this.updateType = updateType; - this.modules = modules; - } - - @Override - public void run() { - updateStatus = new UpdateStatus(UpdateStatus.Status.RUNNING, List.of("Update begins!")); - sendFeedback(actionId); - - if (!CollectionUtils.isEmpty(modules)) { - updateStatus = download(); - sendFeedback(actionId); - final UpdateStatus updateStatus = getUpdateStatus(); - if (updateStatus != null && updateStatus.status() == UpdateStatus.Status.ERROR) { - currentActionId = null; - return; - } - } - - if (updateType != DdiDeployment.HandlingType.SKIP) { - updateStatus = new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update complete!")); - sendFeedback(actionId); - currentActionId = null; - } - } - - private UpdateStatus download() { - updateStatus = new UpdateStatus(UpdateStatus.Status.DOWNLOADING, - modules.stream().flatMap(mod -> mod.getArtifacts().stream()) - .map(art -> "Download starts for: " + art.getFilename() + " with SHA1 hash " - + art.getHashes().getSha1() + " and size " + art.getSize()) - .collect(Collectors.toList())); - sendFeedback(actionId); - - log.info(LOG_PREFIX + "Start download", getTenantId(), getControllerId()); - - final List updateStatusList = new ArrayList<>(); - modules.forEach(module -> module.getArtifacts().forEach(artifact -> { - if (downloadAuthenticationEnabled) { - handleArtifact(getTargetSecurityToken(), gatewayToken, updateStatusList, artifact); - } else { - handleArtifact(null, null, updateStatusList, artifact); - } - })); - - log.info(LOG_PREFIX + "Download complete", getTenantId(), getControllerId()); - - final List messages = new LinkedList<>(); - messages.add("Download complete!"); - updateStatusList.forEach(download -> messages.addAll(download.messages())); - return new UpdateStatus( - updateStatusList.stream().anyMatch(status -> status.status() == UpdateStatus.Status.ERROR) ? - UpdateStatus.Status.ERROR : UpdateStatus.Status.DOWNLOADED, - messages); - } - - private void handleArtifact( - final String targetToken, final String gatewayToken, - final List status, final DdiArtifact artifact) { - artifact.getLink("download").ifPresentOrElse( - // HTTPS - link -> status.add(downloadUrl(link.getHref(), gatewayToken, targetToken, - artifact.getHashes().getSha1(), artifact.getSize())) - , - // HTTP - () -> status.add(downloadUrl( - artifact.getLink("download-http") - .map(Link::getHref) - .orElseThrow(() -> new IllegalArgumentException("Nor https nor http found!")), - gatewayToken, targetToken, - artifact.getHashes().getSha1(), artifact.getSize())) - ); - } - - private UpdateStatus downloadUrl( - final String url, final String gatewayToken, final String targetToken, - final String sha1Hash, final long size) { - if (log.isDebugEnabled()) { - log.debug(LOG_PREFIX + "Downloading {} with token {}, expected sha1 hash {} and size {}", getTenantId(), getControllerId(), url, - hideTokenDetails(targetToken), sha1Hash, size); - } - - try { - return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size); - } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { - log.error(LOG_PREFIX + "Failed to download {}", getTenantId(), getControllerId(), url, e); - return new UpdateStatus(UpdateStatus.Status.ERROR, List.of("Failed to download " + url + ": " + e.getMessage())); - } - - } - - private UpdateStatus readAndCheckDownloadUrl(final String url, final String gatewayToken, - final String targetToken, final String sha1Hash, final long size) - throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException { - long overallread; - final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts(); - final HttpGet request = new HttpGet(url); - - if (StringUtils.hasLength(targetToken)) { - request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); - } else if (StringUtils.hasLength(gatewayToken)) { - request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); - } - - final String sha1HashResult; - try (final CloseableHttpResponse response = httpclient.execute(request)) { - - if (response.getCode() != HttpStatus.OK.value()) { - final String message = wrongStatusCode(url, response); - return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message)); - } - - if (response.getEntity().getContentLength() != size) { - final String message = wrongContentLength(url, size, response); - return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message)); - } - - // Exception squid:S2070 - not used for hashing sensitive - // data - @SuppressWarnings("squid:S2070") - final MessageDigest md = MessageDigest.getInstance("SHA-1"); - - overallread = getOverallRead(response, md); - - if (overallread != size) { - final String message = incompleteRead(url, size, overallread); - return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message)); - } - - sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); - } - - if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { - final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); - return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message)); - } - - final String message = "Downloaded " + url + " (" + overallread + " bytes)"; - log.debug(message); - return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of(message)); - } - - private static long getOverallRead(final CloseableHttpResponse response, final MessageDigest md) - throws IOException { - - long overallread; - - try (final OutputStream os = ByteStreams.nullOutputStream(); - final BufferedOutputStream bos = new BufferedOutputStream(new DigestOutputStream(os, md))) { - - try (BufferedInputStream bis = new BufferedInputStream(response.getEntity().getContent())) { - overallread = ByteStreams.copy(bis, bos); - } - } - - return overallread; - } - - private static String hideTokenDetails(final String targetToken) { - if (targetToken == null) { - return ""; - } - - if (targetToken.isEmpty()) { - return ""; - } - - if (targetToken.length() <= MINIMUM_TOKENLENGTH_FOR_HINT) { - return "***"; - } - - return targetToken.substring(0, 2) + "***" - + targetToken.substring(targetToken.length() - 2, targetToken.length()); - } - - private String wrongHash(final String url, final String sha1Hash, final long overallread, - final String sha1HashResult) { - final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: " - + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)"; - log.error(message, getTenantId(), getControllerId()); - return message; - } - - private String incompleteRead(final String url, final long size, final long overallread) { - final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size - + BUT_GOT_LOG_MESSAGE + overallread + ")"; - log.error(message, getTenantId(), getControllerId()); - return message; - } - - private String wrongContentLength(final String url, final long size, - final CloseableHttpResponse response) { - final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size - + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")"; - log.error(message, getTenantId(), getControllerId()); - return message; - } - - private String wrongStatusCode(final String url, final CloseableHttpResponse response) { - final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getCode() + ")"; - log.error(message, getTenantId(), getControllerId()); - return message; - } - - private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() - throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { - return HttpClients - .custom() - .setConnectionManager( - PoolingHttpClientConnectionManagerBuilder.create() - .setSSLSocketFactory( - new SSLConnectionSocketFactory( - SSLContextBuilder - .create() - .loadTrustMaterial(null, (chain, authType) -> true) - .build())) - .build() - ) - .build(); - } - } -} +} \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateHandler.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateHandler.java new file mode 100644 index 000000000..704a043d4 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateHandler.java @@ -0,0 +1,396 @@ +/** + * Copyright (c) 2024 Contributors to the Eclipse Foundation + * + * 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.sdk.device; + +import com.google.common.io.BaseEncoding; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.eclipse.hawkbit.ddi.json.model.DdiArtifact; +import org.eclipse.hawkbit.ddi.json.model.DdiArtifactHash; +import org.eclipse.hawkbit.ddi.json.model.DdiChunk; +import org.eclipse.hawkbit.ddi.json.model.DdiDeployment; +import org.springframework.hateoas.Link; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Update handler provide plug-in endpoint allowing for customization of the update processing. + */ +public interface UpdateHandler { + + UpdateHandler SKIP = (controller, updateType, modules) -> + new UpdateProcessor(controller, updateType, modules, ArtifactHandler.SKIP); + + /** + * Creates an update processor for a single software update + * + * @param controller controller instance + * @param updateType the update handleing type + * @param modules the modules to be installed + * @return the update processor + */ + UpdateProcessor getUpdateProcessor( + final DdiController controller, + final DdiDeployment.HandlingType updateType, final List modules); + + @Slf4j + class UpdateProcessor implements Runnable { + + private static final String LOG_PREFIX = "[{}:{}] "; + + private static final String DOWNLOAD_LOG_MESSAGE = "Download "; + private static final String BUT_GOT_LOG_MESSAGE = " but got: "; + private static final int MINIMUM_TOKENLENGTH_FOR_HINT = 6; + + private final DdiController ddiController; + + private final DdiDeployment.HandlingType updateType; + private final List modules; + + private final ArtifactHandler artifactHandler; + protected final Map downloads = new HashMap<>(); + + public UpdateProcessor( + final DdiController ddiController, + final DdiDeployment.HandlingType updateType, final List modules, + final ArtifactHandler artifactHandler) { + this.ddiController = ddiController; + + this.updateType = updateType; + this.modules = modules; + + this.artifactHandler = artifactHandler; + } + + @Override + public void run() { + ddiController.sendFeedback(new UpdateStatus(UpdateStatus.Status.RUNNING, List.of("Update begins!"))); + + if (!CollectionUtils.isEmpty(modules)) { + try { + final UpdateStatus updateStatus = download(); + ddiController.sendFeedback(updateStatus); + if (updateStatus.status() == UpdateStatus.Status.ERROR) { + return; + } else { + ddiController.sendFeedback(update()); + } + } finally { + cleanup(); + } + } + + if (updateType != DdiDeployment.HandlingType.SKIP) { + ddiController.sendFeedback( + new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update complete!"))); + } + } + + /** + * Extension point. An overriding implementation could completely skip the default download and provide its own. + * By contract, it shall fill up {@link #downloads}; + * + * @return the status of the download + */ + protected UpdateStatus download() { + ddiController.sendFeedback( + new UpdateStatus( + UpdateStatus.Status.DOWNLOADING, + modules.stream().flatMap(mod -> mod.getArtifacts().stream()) + .map(art -> "Download starts for: " + art.getFilename() + + " with size " + art.getSize() + + " and hashes " + art.getHashes()) + .collect(Collectors.toList()))); + + log.info(LOG_PREFIX + "Start download", ddiController.getTenantId(), ddiController.getControllerId()); + + final List updateStatusList = new ArrayList<>(); + modules.forEach(module -> module.getArtifacts().forEach(artifact -> { + if (ddiController.isDownloadAuthenticationEnabled()) { + handleArtifact( + ddiController.getTargetSecurityToken(), ddiController.getGatewayToken(), + updateStatusList, artifact); + } else { + handleArtifact(null, null, updateStatusList, artifact); + } + })); + + log.info(LOG_PREFIX + "Download complete", ddiController.getTenantId(), ddiController.getControllerId()); + + final List messages = new LinkedList<>(); + messages.add("Download complete!"); + updateStatusList.forEach(download -> messages.addAll(download.messages())); + return new UpdateStatus( + updateStatusList.stream().anyMatch(status -> status.status() == UpdateStatus.Status.ERROR) ? + UpdateStatus.Status.ERROR : UpdateStatus.Status.DOWNLOADED, + messages); + } + + /** + * Extension point. Called after all artifacts has been successfully downloadec. An overriding implementation + * may get the {@link #downloads} map and apply them + */ + protected UpdateStatus update() { + log.info(LOG_PREFIX + "Updated", ddiController.getTenantId(), ddiController.getControllerId()); + return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update applied")); + } + + /** + * Extension point. Called after download and update has been finished. By default it deletes all downloaded + * files (if any). + */ + protected void cleanup() { + downloads.values().forEach(path -> { + if (!path.toFile().delete()) { + log.warn(LOG_PREFIX + "Failed to cleanup {}", + ddiController.getTenantId(), ddiController.getControllerId(), + path.toFile().getAbsolutePath()); + } + }); + log.debug(LOG_PREFIX + "Cleaned up", ddiController.getTenantId(), ddiController.getControllerId()); + } + + private void handleArtifact( + final String targetToken, final String gatewayToken, + final List status, final DdiArtifact artifact) { + artifact.getLink("download").ifPresentOrElse( + // HTTPS + link -> status.add(downloadUrl(link.getHref(), gatewayToken, targetToken, + artifact.getHashes(), artifact.getSize())) + , + // HTTP + () -> status.add(downloadUrl( + artifact.getLink("download-http") + .map(Link::getHref) + .orElseThrow(() -> new IllegalArgumentException("Nor https nor http found!")), + gatewayToken, targetToken, + artifact.getHashes(), artifact.getSize())) + ); + } + + private UpdateStatus downloadUrl( + final String url, final String gatewayToken, final String targetToken, + final DdiArtifactHash hash, final long size) { + if (log.isDebugEnabled()) { + log.debug(LOG_PREFIX + "Downloading {} with token {}, expected hash {} and size {}", + ddiController.getTenantId(), ddiController.getControllerId(), url, + hideTokenDetails(targetToken), hash, size); + } + + try { + return readAndCheckDownloadUrl(url, gatewayToken, targetToken, hash, size); + } catch (final IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { + log.error(LOG_PREFIX + "Failed to download {}", + ddiController.getTenantId(), ddiController.getControllerId(), url, e); + return new UpdateStatus( + UpdateStatus.Status.ERROR, + List.of("Failed to download " + url + ": " + e.getMessage())); + } + } + + private UpdateStatus readAndCheckDownloadUrl(final String url, final String gatewayToken, + final String targetToken, final DdiArtifactHash hash, final long size) + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException { + final Validator sizeValidator = sizeValidator(size); + final Validator hashValidator = hashValidator(hash); + final ArtifactHandler.DownloadHandler downloadHandler = artifactHandler.getDownloadHandler(url); + + try (final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts()) { + final HttpGet request = new HttpGet(url); + if (StringUtils.hasLength(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } else if (StringUtils.hasLength(gatewayToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); + } + + return httpclient.execute(request, response -> { + try { + if (response.getCode() != HttpStatus.OK.value()) { + throw new IllegalStateException("Unexpected status code: " + response.getCode()); + } + + if (response.getEntity().getContentLength() != size) { + throw new IllegalArgumentException("Wrong content length " + "(Expected: " + size + BUT_GOT_LOG_MESSAGE + response.getEntity() + .getContentLength() + ")!"); + } + + final byte[] buff = new byte[32 * 1024]; + try (final InputStream is = response.getEntity().getContent()) { + for (int read; (read = is.read(buff)) != -1; ) { + sizeValidator.read(buff, read); + hashValidator.read(buff, read); + downloadHandler.read(buff, 0, read); + } + } + sizeValidator.validate(); + hashValidator.validate(); + + final String message = "Downloaded " + url + " (" + size + " bytes)"; + log.debug(LOG_PREFIX + message, ddiController.getTenantId(), ddiController.getControllerId()); + downloadHandler.finished(ArtifactHandler.DownloadHandler.Status.SUCCESS); + downloadHandler.download().ifPresent(path -> downloads.put(url, path)); + return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of(message)); + } catch (final Exception e) { + final String message = e.getMessage(); + if (log.isTraceEnabled()) { + log.error(LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed: " + message, + ddiController.getTenantId(), ddiController.getControllerId(), e); + } else { + log.error(LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed: " + message, + ddiController.getTenantId(), ddiController.getControllerId()); + } + downloadHandler.finished(ArtifactHandler.DownloadHandler.Status.ERROR); + return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message)); + } + }); + } + } + + private static String hideTokenDetails(final String targetToken) { + if (targetToken == null) { + return ""; + } + + if (targetToken.isEmpty()) { + return ""; + } + + if (targetToken.length() <= MINIMUM_TOKENLENGTH_FOR_HINT) { + return "***"; + } + + return targetToken.substring(0, 2) + "***" + targetToken.substring(targetToken.length() - 2); + } + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + return HttpClients + .custom() + .setConnectionManager( + PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory( + new SSLConnectionSocketFactory( + SSLContextBuilder + .create() + .loadTrustMaterial(null, (chain, authType) -> true) + .build())) + .build() + ) + .build(); + } + + + private interface Validator { + + void read(final byte[] buff, final int len); + + void validate(); + } + + private static Validator sizeValidator(final long size) { + return new Validator() { + + private int read; + + @Override + public void read(final byte[] buff, final int len) { + read += len; + if (read > size) { + throw new SecurityException("Size mismatch: read more " + + "(Expected: " + size + BUT_GOT_LOG_MESSAGE + read + ")!"); + } + } + + @Override + public void validate() { + if (read != size) { + throw new SecurityException("Size mismatch " + + "(Expected: " + size + BUT_GOT_LOG_MESSAGE + read + ")!"); + } + } + }; + } + + private static Validator hashValidator(final DdiArtifactHash hash) throws NoSuchAlgorithmException { + + class HashValidator { + + private final String expected; + private final MessageDigest messageDigest; + + private HashValidator(final String expected, final MessageDigest messageDigest) { + this.expected = expected; + this.messageDigest = messageDigest; + } + + private void update(final byte[] buff, final int len) { + messageDigest.update(buff, 0, len); + } + + private void check() { + final String actual = BaseEncoding.base16().lowerCase().encode(messageDigest.digest()); + if (!actual.equals(expected)) { + throw new SecurityException( + messageDigest.getAlgorithm() + " hash mismatch " + + "(Expected: " + expected + BUT_GOT_LOG_MESSAGE + actual + ")!"); + } + } + } + + final List hashValidators = new ArrayList<>(3); + if (!ObjectUtils.isEmpty(hash.getSha256())) { + hashValidators.add(new HashValidator(hash.getSha256(), MessageDigest.getInstance("SHA-256"))); + } + if (!ObjectUtils.isEmpty(hash.getSha1())) { + hashValidators.add(new HashValidator(hash.getSha1(), MessageDigest.getInstance("SHA-1"))); + } + if (!ObjectUtils.isEmpty(hash.getMd5())) { + hashValidators.add(new HashValidator(hash.getMd5(), MessageDigest.getInstance("MD5"))); + } + if (hashValidators.isEmpty()) { + throw new SecurityException("No hashes in " + hash + "!"); + } + + return new Validator() { + @Override + public void read(final byte[] buff, final int len) { + hashValidators.forEach(hashValidator -> hashValidator.update(buff, len)); + } + + @Override + public void validate() { + hashValidators.forEach(HashValidator::check); + } + }; + } + } +} \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java index c3c3ca136..aa095279e 100644 --- a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java +++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java @@ -15,13 +15,13 @@ import org.eclipse.hawkbit.ddi.json.model.DdiStatus; import java.util.List; -record UpdateStatus(Status status, List messages) { +public record UpdateStatus(Status status, List messages) { /** * The status to response to the hawkBit update server if an simulated update process should be respond with * successful or failure update. */ - enum Status { + public enum Status { /** * Update has been successful and response the successful update.