Files
op-packages/safeshield/files/usr/libexec/safeshield-statistics-uploader
github-actions[bot] c71da84035
Merge-upstream / merge (push) Canceled after 0s
💋 Sync 2026-10-11 00:16:14
2026-10-11 00:16:14 +08:00

494 lines
15 KiB
Bash
Executable File

#!/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