diff --git a/config.conf.example b/config.conf.example index 8a855787..97c99f59 100644 --- a/config.conf.example +++ b/config.conf.example @@ -46,6 +46,10 @@ low_speed_time = 0 # average transfer speed to be below during "low_speed_time" seconds low_speed_rate = 0 +# average download speed cap in bytes per second for rate limiting +# has no effect when stream_bundle = true +download_speed_limit = 0 + # reboot after a successful update post_update_reboot = false diff --git a/docs/reference.rst b/docs/reference.rst index 8a88400c..9f25e763 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -133,6 +133,12 @@ Optional options: See https://curl.se/libcurl/c/CURLOPT_LOW_SPEED_LIMIT.html. Has no effect when used with ``stream_bundle=true``. +``download_speed_limit=`` + Average download speed cap for rate-limited environments. + Defaults to ``0`` (unlimited). + See https://curl.se/libcurl/c/CURLOPT_MAX_RECV_SPEED_LARGE.html. + Has no effect when used with ``stream_bundle=true``. + ``resume_downloads=`` Whether to resume aborted downloads or not. Defaults to ``false``. diff --git a/include/config-file.h b/include/config-file.h index 49826b6c..fa15b7fc 100644 --- a/include/config-file.h +++ b/include/config-file.h @@ -31,6 +31,7 @@ typedef struct Config_ { int retry_wait; /**< wait between retries */ int low_speed_time; /**< time to be below the speed to trigger low speed abort */ int low_speed_rate; /**< low speed limit to abort transfer */ + gint64 download_speed_limit; /**< average download speed cap for rate limiting */ GLogLevelFlags log_level; /**< log level */ GHashTable* device; /**< Additional attributes sent to hawkBit */ gboolean send_download_authentication; /**< Send security header in download requests */ diff --git a/src/config-file.c b/src/config-file.c index b65a44a0..bfc5f5c7 100644 --- a/src/config-file.c +++ b/src/config-file.c @@ -152,6 +152,46 @@ static gboolean get_key_int(GKeyFile *key_file, const gchar *group, const gchar return TRUE; } +/** + * @brief Get 64-bit integer value from key_file for key in group, default_value must be specified, + * returned in case key not found in group. + * + * @param[in] key_file GKeyFile to look value up + * @param[in] group A group name + * @param[in] key A key + * @param[out] value Output 64-bit integer value + * @param[in] default_value Return this value in case no value found + * @param[out] error Error + * @return FALSE on error (error is set), TRUE otherwise. Note that TRUE is returned if key in + * group is not found, value is set to default_value in this case. + */ +static gboolean get_key_int64(GKeyFile *key_file, const gchar *group, const gchar *key, gint64 *value, + const gint64 default_value, GError **error) +{ + GError *ierror = NULL; + gint64 val; + + g_return_val_if_fail(key_file, FALSE); + g_return_val_if_fail(group, FALSE); + g_return_val_if_fail(key, FALSE); + g_return_val_if_fail(value, FALSE); + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + + val = g_key_file_get_int64(key_file, group, key, &ierror); + + if (g_error_matches(ierror, G_KEY_FILE_ERROR, G_KEY_FILE_ERROR_KEY_NOT_FOUND)) { + g_clear_error(&ierror); + *value = default_value; + return TRUE; + } else if (ierror) { + g_propagate_error(error, ierror); + return FALSE; + } + + *value = val; + return TRUE; +} + /** * @brief Get GHashTable containing keys/values from group in key_file. * @@ -328,6 +368,8 @@ Config* load_config_file(const gchar *config_file, GError **error) return NULL; if (!get_key_int(ini_file, "client", "low_speed_time", &config->low_speed_time, 60, error)) return NULL; + if (!get_key_int64(ini_file, "client", "download_speed_limit", &config->download_speed_limit, 0, error)) + return NULL; if (!get_key_bool(ini_file, "client", "resume_downloads", &config->resume_downloads, FALSE, error)) return NULL; diff --git a/src/hawkbit-client.c b/src/hawkbit-client.c index a6d6aed9..e39997fa 100644 --- a/src/hawkbit-client.c +++ b/src/hawkbit-client.c @@ -403,6 +403,8 @@ static gboolean get_binary(const gchar *download_url, const gchar *file, curl_of curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, hawkbit_config->low_speed_time); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, hawkbit_config->low_speed_rate); + curl_easy_setopt(curl, CURLOPT_MAX_RECV_SPEED_LARGE, (curl_off_t) hawkbit_config->download_speed_limit); + curl_easy_setopt(curl, CURLOPT_RESUME_FROM_LARGE, resume_from); if (hawkbit_config->send_download_authentication && diff --git a/test/test_download.py b/test/test_download.py index 38deb763..c11f58bc 100644 --- a/test/test_download.py +++ b/test/test_download.py @@ -1,7 +1,9 @@ # SPDX-License-Identifier: LGPL-2.1-only # SPDX-FileCopyrightText: 2021-2025 Bastian Krause , Pengutronix +import os import re +import time from helper import run @@ -193,3 +195,29 @@ def test_download_only_without_auth_header(hawkbit, adjust_config, assign_bundle assert status[0]['type'] == 'downloaded' # check last status message assert 'File checksum OK.' in status[0]['messages'] + +def test_download_speed_limit(hawkbit, assign_bundle, adjust_config, rauc_bundle): + """Test client-side download speed limiting.""" + bundle_size = os.path.getsize(rauc_bundle) + speed_limit = 100 * 1024 # 100KB/s + min_time = bundle_size / speed_limit * 0.8 # 20% tolerance since cap is across average speed + + config = adjust_config({'client': {'download_speed_limit': str(speed_limit)}}) + assign_bundle(params={'type': 'downloadonly'}) # Don't attempt installation that would fail + + start_time = time.monotonic() + out, err, exitcode = run(f'rauc-hawkbit-updater -c "{config}" -r', timeout=120) + elapsed_time = time.monotonic() - start_time + + assert 'Start downloading' in out + assert 'Download complete.' in out + assert 'File checksum OK.' in out + assert err == '' + assert exitcode == 0 + + status = hawkbit.get_action_status() + assert status[0]['type'] == 'downloaded' + + assert elapsed_time >= min_time, \ + f'Download too fast: {bundle_size} bytes in {elapsed_time:.2f}s ' \ + f'(average speed: {bundle_size/elapsed_time:.0f} B/s, limit: {speed_limit} B/s)'