#!/bin/sh # shellcheck shell=ash # Variables are populated by sourced SafeShield helpers and ss_load_config(). # shellcheck disable=SC2154 LC_ALL=C export LC_ALL umask 077 SS_STATS_UPLOAD_FUNCTIONS_LIB="${SS_STATS_UPLOAD_FUNCTIONS_LIB:-/lib/functions.sh}" SS_STATS_UPLOAD_CORE_LIB="${SS_STATS_UPLOAD_CORE_LIB:-/usr/lib/safeshield/core.sh}" SS_STATS_UPLOAD_INTERVAL_S="${SS_STATS_UPLOAD_INTERVAL_S:-1800}" SS_STATS_UPLOAD_IDLE_WAIT_S="${SS_STATS_UPLOAD_IDLE_WAIT_S:-15}" SS_STATS_UPLOAD_RETRY_DELAY_S="${SS_STATS_UPLOAD_RETRY_DELAY_S:-2}" SS_STATS_UPLOAD_FULL_EVERY="${SS_STATS_UPLOAD_FULL_EVERY:-23}" SS_STATS_UPLOAD_DENIED_RECHECK_S="${SS_STATS_UPLOAD_DENIED_RECHECK_S:-43200}" SS_STATS_UPLOAD_RESPONSE_FILE="${SS_STATS_UPLOAD_RESPONSE_FILE:-/tmp/safeshield/statistics/upload.response.json}" # shellcheck disable=SC1090 . "$SS_STATS_UPLOAD_FUNCTIONS_LIB" # shellcheck disable=SC1090 . "$SS_STATS_UPLOAD_CORE_LIB" ss_should_terminate=0 upload_sleep_pid='' upload_state_generation='' upload_state_last_success_seq=0 upload_state_last_full_seq=0 upload_state_recent_successes=0 upload_state_needs_full=1 upload_pending_mode='' upload_pending_generation='' upload_pending_seq=0 upload_handle_term() { ss_should_terminate=1 if [ -n "$upload_sleep_pid" ]; then kill "$upload_sleep_pid" 2>/dev/null || true fi } upload_sleep() { local seconds="$1" local rc=0 [ "$ss_should_terminate" -eq 1 ] && return 1 [ "$seconds" -gt 0 ] 2>/dev/null || return 0 sleep "$seconds" & upload_sleep_pid=$! wait "$upload_sleep_pid" 2>/dev/null || rc=$? upload_sleep_pid='' [ "$ss_should_terminate" -eq 1 ] && return 1 [ "$rc" -eq 0 ] } upload_state_reset() { upload_state_generation='' upload_state_last_success_seq=0 upload_state_last_full_seq=0 upload_state_recent_successes=0 upload_state_needs_full=1 } upload_state_load() { local old_ifs upload_state_reset [ -s "$SS_STATISTICS_UPLOAD_STATE_FILE" ] || return 0 old_ifs="$IFS" IFS="$(printf '\t')" read -r upload_state_generation upload_state_last_success_seq upload_state_last_full_seq upload_state_recent_successes upload_state_needs_full <"$SS_STATISTICS_UPLOAD_STATE_FILE" || true IFS="$old_ifs" is_valid_integer "$upload_state_last_success_seq" || upload_state_last_success_seq=0 is_valid_integer "$upload_state_last_full_seq" || upload_state_last_full_seq=0 is_valid_integer "$upload_state_recent_successes" || upload_state_recent_successes=0 case "$upload_state_needs_full" in 0 | 1) ;; *) upload_state_needs_full=1 ;; esac } upload_state_save() { local tmp="${SS_STATISTICS_UPLOAD_STATE_FILE}.tmp.$$" mkdir -p "${SS_STATISTICS_UPLOAD_STATE_FILE%/*}" || return 1 printf '%s\t%s\t%s\t%s\t%s\n' \ "$upload_state_generation" \ "$upload_state_last_success_seq" \ "$upload_state_last_full_seq" \ "$upload_state_recent_successes" \ "$upload_state_needs_full" >"$tmp" || { rm -f "$tmp" return 1 } mv -f "$tmp" "$SS_STATISTICS_UPLOAD_STATE_FILE" || { rm -f "$tmp" return 1 } } upload_mark_needs_full() { local generation="${1:-$upload_state_generation}" if [ -n "$generation" ] && [ "$upload_state_generation" != "$generation" ]; then upload_state_generation="$generation" upload_state_last_success_seq=0 upload_state_last_full_seq=0 upload_state_recent_successes=0 fi upload_state_needs_full=1 upload_state_save >/dev/null 2>&1 || true } upload_pending_clear() { rm -f "$SS_STATISTICS_UPLOAD_PENDING_FILE" "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" upload_pending_mode='' upload_pending_generation='' upload_pending_seq=0 } upload_pending_load() { local old_ifs payload_generation payload_seq upload_pending_mode='' upload_pending_generation='' upload_pending_seq=0 [ -s "$SS_STATISTICS_UPLOAD_PENDING_FILE" ] || return 1 [ -s "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" ] || { upload_pending_clear return 1 } old_ifs="$IFS" IFS="$(printf '\t')" read -r upload_pending_mode upload_pending_generation upload_pending_seq <"$SS_STATISTICS_UPLOAD_PENDING_META_FILE" || true IFS="$old_ifs" case "$upload_pending_mode" in full | recent) ;; *) upload_pending_clear return 1 ;; esac is_valid_integer "$upload_pending_seq" || { upload_pending_clear return 1 } [ "$upload_pending_seq" -gt 0 ] 2>/dev/null || { upload_pending_clear return 1 } payload_generation="$(ss_json_get_file "$SS_STATISTICS_UPLOAD_PENDING_FILE" '@.generation_id')" payload_seq="$(ss_json_get_file "$SS_STATISTICS_UPLOAD_PENDING_FILE" '@.snapshot_seq')" if [ "$payload_generation" != "$upload_pending_generation" ] || [ "$payload_seq" != "$upload_pending_seq" ]; then upload_pending_clear return 1 fi return 0 } upload_source_is_v3() { local file="$1" local schema_name schema_version seq generation [ -s "$file" ] || return 1 schema_name="$(ss_json_get_file "$file" '@.schema.name')" schema_version="$(ss_json_get_file "$file" '@.schema.version')" generation="$(ss_json_get_file "$file" '@.generation_id')" seq="$(ss_json_get_file "$file" '@.snapshot_seq')" [ "$schema_name" = 'safeshield.statistics' ] || return 1 [ "$schema_version" = '3' ] || return 1 [ -n "$generation" ] || return 1 is_valid_integer "$seq" || return 1 [ "$seq" -gt 0 ] 2>/dev/null } upload_pending_create() { local source mode source_generation source_seq full_generation full_seq local tmp_payload tmp_meta upload_state_load upload_source_is_v3 "$SS_STATISTICS_JSON_FILE" || return 1 full_generation="$(ss_json_get_file "$SS_STATISTICS_JSON_FILE" '@.generation_id')" full_seq="$(ss_json_get_file "$SS_STATISTICS_JSON_FILE" '@.snapshot_seq')" if [ "$upload_state_generation" != "$full_generation" ]; then upload_state_generation="$full_generation" upload_state_last_success_seq=0 upload_state_last_full_seq=0 upload_state_recent_successes=0 upload_state_needs_full=1 upload_state_save >/dev/null 2>&1 || true fi mode='recent' source="$SS_STATISTICS_UPLOAD_JSON_FILE" if [ "$upload_state_needs_full" = '1' ] || [ "$upload_state_last_full_seq" -le 0 ] 2>/dev/null || [ "$upload_state_recent_successes" -ge "$SS_STATS_UPLOAD_FULL_EVERY" ] 2>/dev/null; then mode='full' source="$SS_STATISTICS_JSON_FILE" elif ! upload_source_is_v3 "$source"; then mode='full' source="$SS_STATISTICS_JSON_FILE" fi tmp_payload="${SS_STATISTICS_UPLOAD_PENDING_FILE}.tmp.$$" tmp_meta="${SS_STATISTICS_UPLOAD_PENDING_META_FILE}.tmp.$$" mkdir -p "${SS_STATISTICS_UPLOAD_PENDING_FILE%/*}" || return 1 cp "$source" "$tmp_payload" || { rm -f "$tmp_payload" return 1 } upload_source_is_v3 "$tmp_payload" || { rm -f "$tmp_payload" return 1 } source_generation="$(ss_json_get_file "$tmp_payload" '@.generation_id')" source_seq="$(ss_json_get_file "$tmp_payload" '@.snapshot_seq')" if [ "$source_generation" != "$full_generation" ]; then rm -f "$tmp_payload" return 1 fi if [ "$mode" = 'recent' ] && [ "$source_seq" != "$full_seq" ]; then # The collector publishes the full snapshot before its recent projection. # Avoid promoting a previous projection while those two atomic writes cross. rm -f "$tmp_payload" return 1 fi if [ "$source_seq" -le "$upload_state_last_success_seq" ] 2>/dev/null; then rm -f "$tmp_payload" return 2 fi printf '%s\t%s\t%s\n' "$mode" "$source_generation" "$source_seq" >"$tmp_meta" || { rm -f "$tmp_payload" "$tmp_meta" return 1 } mv -f "$tmp_payload" "$SS_STATISTICS_UPLOAD_PENDING_FILE" || { rm -f "$tmp_payload" "$tmp_meta" return 1 } mv -f "$tmp_meta" "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" || { rm -f "$tmp_meta" upload_pending_clear return 1 } upload_pending_mode="$mode" upload_pending_generation="$source_generation" upload_pending_seq="$source_seq" return 0 } upload_ensure_credentials() { ss_statistics_upload_denied && return 2 ss_statistics_load_upload_credentials && return 0 log_info 'Refreshing Hub statistics upload credentials' ss_statistics_refresh_upload_credentials || return $? ss_statistics_load_upload_credentials } upload_response_valid() { local response="$1" local status generation server_seq [ -s "$response" ] || return 1 status="$(ss_json_get_file "$response" '@.status')" generation="$(ss_json_get_file "$response" '@.generation_id')" server_seq="$(ss_json_get_file "$response" '@.last_snapshot_seq')" case "$status" in applied | duplicate | stale) ;; *) return 1 ;; esac [ "$generation" = "$upload_pending_generation" ] || return 1 is_valid_integer "$server_seq" || return 1 [ "$server_seq" -ge "$upload_pending_seq" ] 2>/dev/null || return 1 SS_STATS_UPLOAD_RESPONSE_STATUS="$status" SS_STATS_UPLOAD_RESPONSE_SEQ="$server_seq" return 0 } upload_record_success() { local result_status="$1" local server_seq="$2" upload_state_load if [ "$upload_state_generation" != "$upload_pending_generation" ]; then upload_state_generation="$upload_pending_generation" upload_state_last_success_seq=0 upload_state_last_full_seq=0 upload_state_recent_successes=0 upload_state_needs_full=1 fi if [ "$server_seq" -gt "$upload_state_last_success_seq" ] 2>/dev/null; then upload_state_last_success_seq="$server_seq" fi if [ "$upload_pending_mode" = 'full' ] && [ "$result_status" != 'stale' ]; then upload_state_last_full_seq="$upload_pending_seq" upload_state_recent_successes=0 upload_state_needs_full=0 else if [ "$result_status" = 'stale' ]; then upload_state_needs_full=1 elif [ "$upload_state_needs_full" = '0' ]; then upload_state_recent_successes=$((upload_state_recent_successes + 1)) fi fi upload_state_save >/dev/null 2>&1 || true } upload_record_failure() { upload_mark_needs_full "$upload_pending_generation" } upload_send_pending() { local retries reauthenticated=0 response_status response_seq credentials_rc=0 refresh_rc=0 upload_pending_load || return 1 upload_ensure_credentials || credentials_rc=$? case "$credentials_rc" in 0) ;; 2) upload_pending_clear return 2 ;; *) upload_record_failure 'statistics_credentials_unavailable' return 1 ;; esac retries=1 while [ "$retries" -le "${ss_download_retry:-3}" ]; do [ "$ss_should_terminate" -eq 1 ] && return 130 rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE" if ss_http_post_json \ "$SS_STATISTICS_UPLOAD_URL" \ "$SS_STATISTICS_UPLOAD_PENDING_FILE" \ "$SS_STATS_UPLOAD_RESPONSE_FILE" \ "Bearer $SS_STATISTICS_UPLOAD_TOKEN"; then if upload_response_valid "$SS_STATS_UPLOAD_RESPONSE_FILE"; then response_status="$SS_STATS_UPLOAD_RESPONSE_STATUS" response_seq="$SS_STATS_UPLOAD_RESPONSE_SEQ" upload_record_success "$response_status" "$response_seq" log_info "Uploaded SafeShield statistics (${upload_pending_mode}, seq=${upload_pending_seq}, status=${response_status})" upload_pending_clear rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE" return 0 fi upload_record_failure 'statistics_upload_invalid_response' '200' log_warn 'Hub statistics upload returned an invalid success response; preserving pending payload for retry' rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE" return 1 fi case "${SS_HTTP_STATUS:-}" in 401) if [ "$reauthenticated" -eq 0 ]; then refresh_rc=0 reauthenticated=1 ss_statistics_clear_upload_credentials ss_statistics_refresh_upload_credentials || refresh_rc=$? if [ "$refresh_rc" -eq 0 ] && ss_statistics_load_upload_credentials; then continue fi if [ "$refresh_rc" -eq 2 ]; then log_info 'Cloud statistics upload is no longer enabled for the current account entitlement' upload_pending_clear return 2 fi fi upload_record_failure 'statistics_upload_auth_failed' '401' log_warn 'Hub rejected statistics upload credentials' return 1 ;; 400 | 409 | 413) upload_record_failure "statistics_upload_http_${SS_HTTP_STATUS}" "$SS_HTTP_STATUS" log_error "Hub rejected statistics payload (HTTP ${SS_HTTP_STATUS}); discarding this snapshot and scheduling full reconciliation" upload_pending_clear rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE" return 2 ;; esac upload_record_failure 'statistics_upload_failed' "${SS_HTTP_STATUS:-0}" retries=$((retries + 1)) if [ "$retries" -le "${ss_download_retry:-3}" ]; then upload_sleep "$SS_STATS_UPLOAD_RETRY_DELAY_S" || return 130 fi done log_warn "Statistics upload failed after ${ss_download_retry:-3} attempt(s); pending payload will be retried" return 1 } main() { local create_rc=0 wait_s entitlement_rc ss_load_config || return 1 [ "${ss_enabled}" = '1' ] || return 0 [ "${ss_statistics_enabled}" = '1' ] || return 0 mkdir -p "$SS_STATISTICS_DIR" || return 1 is_valid_integer "$SS_STATS_UPLOAD_INTERVAL_S" || SS_STATS_UPLOAD_INTERVAL_S=1800 [ "$SS_STATS_UPLOAD_INTERVAL_S" -ge 60 ] 2>/dev/null || SS_STATS_UPLOAD_INTERVAL_S=1800 is_valid_integer "$SS_STATS_UPLOAD_IDLE_WAIT_S" || SS_STATS_UPLOAD_IDLE_WAIT_S=15 [ "$SS_STATS_UPLOAD_IDLE_WAIT_S" -ge 1 ] 2>/dev/null || SS_STATS_UPLOAD_IDLE_WAIT_S=15 is_valid_integer "$SS_STATS_UPLOAD_RETRY_DELAY_S" || SS_STATS_UPLOAD_RETRY_DELAY_S=2 is_valid_integer "$SS_STATS_UPLOAD_FULL_EVERY" || SS_STATS_UPLOAD_FULL_EVERY=23 [ "$SS_STATS_UPLOAD_FULL_EVERY" -ge 1 ] 2>/dev/null || SS_STATS_UPLOAD_FULL_EVERY=23 is_valid_integer "$SS_STATS_UPLOAD_DENIED_RECHECK_S" || SS_STATS_UPLOAD_DENIED_RECHECK_S=43200 [ "$SS_STATS_UPLOAD_DENIED_RECHECK_S" -ge 60 ] 2>/dev/null || SS_STATS_UPLOAD_DENIED_RECHECK_S=43200 upload_state_load while [ "$ss_should_terminate" -eq 0 ]; do ss_load_config || { upload_sleep "$SS_STATS_UPLOAD_IDLE_WAIT_S" || break continue } if [ "${ss_enabled}" != '1' ] || [ "${ss_statistics_enabled}" != '1' ]; then break fi wait_s="$SS_STATS_UPLOAD_INTERVAL_S" if ss_statistics_upload_denied; then upload_pending_clear if ss_statistics_upload_denied_recheck_due "$SS_STATS_UPLOAD_DENIED_RECHECK_S"; then log_info 'Rechecking Hub statistics upload entitlement' # Stamp this attempt before network I/O so a failed device sync cannot # turn into a routine upload retry loop while entitlement is denied. ss_statistics_set_upload_entitlement denied >/dev/null 2>&1 || true entitlement_rc=0 ss_statistics_refresh_upload_credentials || entitlement_rc=$? case "$entitlement_rc" in 0) ;; 2) upload_sleep "$wait_s" || break continue ;; *) log_warn 'Hub statistics entitlement recheck failed; keeping cloud upload disabled' upload_sleep "$wait_s" || break continue ;; esac fi if ss_statistics_upload_denied; then upload_sleep "$wait_s" || break continue fi fi if upload_pending_load; then upload_send_pending || true else create_rc=0 upload_pending_create || create_rc=$? case "$create_rc" in 0) upload_send_pending || true ;; 1) wait_s="$SS_STATS_UPLOAD_IDLE_WAIT_S" ;; 2) # The collector has not produced a sequence newer than the last ACK yet. ;; esac fi upload_sleep "$wait_s" || break done rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE" return 0 } if [ "${SS_STATS_UPLOAD_LIBRARY_ONLY:-0}" != '1' ]; then trap 'upload_handle_term' TERM INT trap 'rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE"' EXIT main "$@" fi