🎉 Sync 2026-09-27 21:03:26

This commit is contained in:
github-actions[bot] committed 2026-09-27 21:03:26 +08:00
1 parent f4559bbed1
commit 065763f7fd
673 files changed
+261285 -34160

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
config safeshield 'config'
option enabled '1'
option verbosity '2'
# Device identity / profile
option device_vendor ''
option device_model ''
option device_arch ''
option device_memory_mb ''
# Local overrides
option apply_local_overrides '1'
# Artifact validation / installation
option max_blocklist_file_size_kb '30000'
option min_valid_line_count '3000'
option compress_blocklist '0'
option initial_dnsmasq_restart '0'
option dnsmasq_sanity_check '1'
# Local statistics (runtime state in RAM; supported profiles also persist aggregates)
option statistics_enabled '1'
option statistics_snapshot_interval_s '60'
option statistics_retention_hours '168'
# Download / refresh runtime
option download_timeout '10'
option download_retry '3'
option pause_timeout '20'
option boot_start_delay_s '30'
option refresh_on_boot '1'
option refresh_interval_s '28800'
option require_wan '1'
option debug '0'
config identity 'identity'
option physical_fingerprint ''
option fingerprint_version '1'
option identity_provider ''
option identity_source ''
option identity_strength ''
option identity_profile ''
option device_code ''
option device_code_source ''
option installation_id ''
option created_at ''
option updated_at ''
+417
View File
@@ -0,0 +1,417 @@
#!/bin/sh /etc/rc.common
# shellcheck shell=ash
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
# Used by /etc/rc.common
# shellcheck disable=SC2034
START=20
# shellcheck disable=SC2034
STOP=1
# shellcheck disable=SC2034
USE_PROCD=1
LC_ALL=C
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
readonly PKG_NAME='safeshield'
readonly PKG_VERSION='0.3.24-r1'
if type extra_command >/dev/null 2>&1; then
extra_command 'apply_local_rules' 'Apply local allow/block rules using the cached Hub artifact'
extra_command 'force_download' 'Resolve and download latest Hub artifact'
extra_command 'pause' 'Pause the service'
extra_command 'refresh_once' 'Run one immediate refresh in foreground'
extra_command 'reconcile_statistics' 'Reconcile SafeShield statistics runtime without restarting refreshd'
extra_command 'resume' 'Resume the service'
extra_command 'show_blocklist' 'List currently blocked domains'
extra_command 'sizes' 'Display SafeShield blocklist size information'
extra_command 'statistics' 'Display local SafeShield DNS statistics'
extra_command 'version' 'Show version information'
fi
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/lib/functions.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/core.sh"
boot() {
rc_procd start_service boot
}
start_service() {
local statistics_dnsmasq_changed=0
local blocklist_restart_expected=0
ss_load_config || {
log_error "Failed to load config"
return 1
}
ss_identity_ensure "$(ss_detect_device_model 2>/dev/null || echo OpenWrt)" "$(ss_detect_device_arch 2>/dev/null || echo unknown)" || {
log_error "Failed to initialize SafeShield device identity"
return 1
}
ss_mkdirs || {
log_error "Failed to create runtime directories"
return 1
}
if [ "${ss_enabled}" != "1" ]; then
log_warn "${PKG_NAME} is disabled"
ss_statistics_request_rebaseline || {
log_error "Failed to mark statistics for rebaseline while SafeShield is disabled"
return 1
}
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 0)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
[ -f "${SS_BLOCKLIST_FILE}" ] && blocklist_restart_expected=1
ss_runtime_deactivate_blocklist || {
log_error "Failed to deactivate SafeShield blocklist"
return 1
}
if [ "$statistics_dnsmasq_changed" = "1" ] && [ "$blocklist_restart_expected" = "0" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
ss_status_set status "disabled"
ss_status_set stage "disabled"
ss_status_set last_result "disabled"
ss_status_set next_refresh_at "0"
return 0
fi
ss_require_supported_dnsmasq || {
ss_status_set stage "dnsmasq_version_check"
ss_status_mark_failure "$(ss_dnsmasq_check_error)"
return 1
}
ss_statistics_disable_if_dnsmasq_unpatched || {
log_error "Failed to apply the dnsmasq compatibility guard for statistics"
return 1
}
if [ "${ss_statistics_enabled}" != "1" ]; then
ss_statistics_request_rebaseline || {
log_error "Failed to mark statistics for rebaseline while statistics are inactive"
return 1
}
fi
ss_reconcile_installed_blocklists_for_dnsmasq || {
log_error "Failed to reconcile installed blocklist directives for dnsmasq"
ss_status_mark_failure "blocklist_directive_migration_failed"
return 1
}
if [ "${ss_statistics_enabled}" = "1" ]; then
ss_ensure_dnsmasq_confdir || {
log_error "dnsmasq confdir is unavailable while enabling statistics"
return 1
}
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 1)" || {
log_error "Failed to reconcile legacy dnsmasq statistics logging"
return 1
}
else
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 0)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
fi
if [ -f "${SS_BLOCKLIST_FILE}" ] || [ -f "${SS_INACTIVE_BLOCKLIST_FILE}" ]; then
blocklist_restart_expected=1
fi
ss_runtime_activate_blocklist || {
log_error "Failed to activate SafeShield blocklist"
return 1
}
if [ "$statistics_dnsmasq_changed" = "1" ] && [ "$blocklist_restart_expected" = "0" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
procd_open_instance
procd_set_param command /usr/libexec/safeshield-refreshd
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param file /etc/config/safeshield
procd_set_param data service safeshield
procd_close_instance
if [ "${ss_statistics_enabled}" = "1" ]; then
ss_statistics_add_procd_instance
ss_statistics_add_upload_procd_instance
fi
}
stop_service() {
local statistics_dnsmasq_changed=0
local blocklist_restart_expected=0
ss_load_config >/dev/null 2>&1 || true
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 0)" || {
log_error "Failed to remove legacy dnsmasq statistics logging while stopping service"
return 1
}
[ -f "${SS_BLOCKLIST_FILE}" ] && blocklist_restart_expected=1
ss_runtime_deactivate_blocklist || {
log_error "Failed to deactivate SafeShield blocklist while stopping service"
return 1
}
if [ "$statistics_dnsmasq_changed" = "1" ] && [ "$blocklist_restart_expected" = "0" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
ss_status_set next_refresh_at "0"
if [ "${ss_enabled:-0}" = "1" ]; then
ss_status_set status "idle"
ss_status_set stage "stopped"
ss_status_set last_result "stopped"
else
ss_status_set status "disabled"
ss_status_set stage "disabled"
ss_status_set last_result "disabled"
fi
return 0
}
reconcile_statistics() {
ss_load_config || {
log_error "Failed to load config while reconciling statistics"
return 1
}
ss_mkdirs || {
log_error "Failed to create runtime directories while reconciling statistics"
return 1
}
ss_statistics_reconcile_runtime
}
reload_service() {
stop
start
}
restart_service() {
stop
start
}
service_triggers() {
procd_add_reload_trigger 'safeshield'
procd_add_reload_interface_trigger 'wan' /etc/init.d/safeshield reload
procd_add_reload_interface_trigger 'wan6' /etc/init.d/safeshield reload
}
status_service() {
local status stage valid_line_count
local enabled refresh_interval_s refresh_on_boot require_wan boot_delay
local license_key artifact_tier artifact_version license_plan device_profile
local artifact_source_count artifact_block_source_count artifact_allow_source_count
local dnsmasq_version dnsmasq_min_version
local statistics_enabled statistics_snapshot_interval_s statistics_retention_hours
if ! ss_load_config >/dev/null 2>&1; then
printf '%s\n' 'config: load failed'
fi
enabled="$(ss_config_get config enabled 0)"
refresh_interval_s="$(ss_config_get config refresh_interval_s 28800)"
refresh_on_boot="$(ss_config_get config refresh_on_boot 1)"
require_wan="$(ss_config_get config require_wan 1)"
boot_delay="$(ss_config_get config boot_start_delay_s 30)"
license_key="$(ss_config_get config license_key '')"
statistics_enabled="$(ss_config_get config statistics_enabled 1)"
statistics_snapshot_interval_s="$(ss_config_get config statistics_snapshot_interval_s 60)"
statistics_retention_hours="$(ss_config_get config statistics_retention_hours 168)"
status=""
stage=""
valid_line_count="0"
if [ -f "${RUNNING_STATUS_FILE}" ]; then
status="$(json get status 2>/dev/null)"
stage="$(json get stage 2>/dev/null)"
valid_line_count="$(json get valid_line_count 2>/dev/null)"
artifact_tier="$(json get artifact_tier 2>/dev/null)"
artifact_version="$(json get artifact_version 2>/dev/null)"
artifact_source_count="$(json get artifact_source_count 2>/dev/null)"
artifact_block_source_count="$(json get artifact_block_source_count 2>/dev/null)"
artifact_allow_source_count="$(json get artifact_allow_source_count 2>/dev/null)"
license_plan="$(json get license_plan 2>/dev/null)"
device_profile="$(json get device_profile 2>/dev/null)"
dnsmasq_version="$(json get dnsmasq_version 2>/dev/null)"
dnsmasq_min_version="$(json get dnsmasq_min_version 2>/dev/null)"
fi
[ -n "$status" ] || status="idle"
[ -n "$valid_line_count" ] || valid_line_count="0"
printf 'enabled: %s\n' "$enabled"
printf 'refresh_interval_s: %s\n' "$refresh_interval_s"
printf 'refresh_on_boot: %s\n' "$refresh_on_boot"
printf 'require_wan: %s\n' "$require_wan"
printf 'boot_start_delay_s: %s\n' "$boot_delay"
printf 'license_key: %s\n' "$(ss_mask_secret "$license_key")"
if [ -f "${SS_BLOCKLIST_FILE}" ]; then
printf '%s\n' 'blocklist: installed'
else
printf '%s\n' 'blocklist: not installed'
fi
if check_dns_runtime; then
printf '%s\n' 'dnsmasq: ok'
else
printf '%s\n' 'dnsmasq: failed'
fi
[ -n "$dnsmasq_version" ] && printf 'dnsmasq_version: %s\n' "$dnsmasq_version"
printf 'dnsmasq_min_version: %s\n' "${dnsmasq_min_version:-$SS_MIN_DNSMASQ_VERSION}"
printf 'status: %s\n' "$status"
[ -n "$stage" ] && printf 'stage: %s\n' "$stage"
[ -n "$license_plan" ] && printf 'license_plan: %s\n' "$license_plan"
[ -n "$device_profile" ] && printf 'device_profile: %s\n' "$device_profile"
[ -n "$artifact_tier" ] && printf 'artifact_tier: %s\n' "$artifact_tier"
[ -n "$artifact_version" ] && printf 'artifact_version: %s\n' "$artifact_version"
[ -n "$artifact_source_count" ] && printf 'artifact_source_count: %s\n' "$artifact_source_count"
[ -n "$artifact_block_source_count" ] && printf 'artifact_block_source_count: %s\n' "$artifact_block_source_count"
[ -n "$artifact_allow_source_count" ] && printf 'artifact_allow_source_count: %s\n' "$artifact_allow_source_count"
printf 'valid_line_count: %s\n' "$valid_line_count"
printf 'statistics_enabled: %s\n' "$statistics_enabled"
printf 'statistics_snapshot_interval_s: %s\n' "$statistics_snapshot_interval_s"
printf 'statistics_retention_hours: %s\n' "$statistics_retention_hours"
if procd_running safeshield statistics; then
printf 'statistics_collector: running\n'
else
printf 'statistics_collector: stopped\n'
fi
if procd_running safeshield; then
printf '%s\n' 'refreshd: running'
else
printf '%s\n' 'refreshd: stopped'
fi
}
apply_local_rules() {
ss_load_config || return 1
ss_mkdirs || return 1
safeshield_apply_local_rules
}
refresh_once() {
ss_load_config || return 1
ss_mkdirs || return 1
safeshield_force_download
}
force_download() {
refresh_once "$@"
}
pause() {
local timeout
ss_load_config || return 1
timeout="${1:-$ss_pause_timeout}"
if ! is_valid_integer "$timeout"; then
log_error "Invalid pause timeout: ${timeout}"
return 1
fi
log_info "Pausing ${PKG_NAME} for ${timeout} seconds"
ss_export_existing_blocklist >/dev/null 2>&1 || true
rm -f "${SS_BLOCKLIST_FILE}"
dnsmasq_restart
sleep "$timeout"
log_info "Resuming ${PKG_NAME} after pause"
ss_restore_previous_blocklist >/dev/null 2>&1 || true
dnsmasq_restart
}
resume() {
log_info "Resuming ${PKG_NAME}"
ss_restore_previous_blocklist >/dev/null 2>&1 || true
dnsmasq_restart
}
show_blocklist() {
if [ -f "${SS_BLOCKLIST_FILE}" ]; then
cat "${SS_BLOCKLIST_FILE}"
else
printf '%s\n' 'No installed blocklist'
fi
}
sizes() {
local blocklist_file_size_kb backup_file_size_kb valid_line_count
ss_load_config || return 1
blocklist_file_size_kb="0"
backup_file_size_kb="0"
valid_line_count="0"
if [ -f "${SS_BLOCKLIST_FILE}" ]; then
blocklist_file_size_kb="$(du -k "${SS_BLOCKLIST_FILE}" 2>/dev/null | awk '{print $1}')"
valid_line_count="$(grep -c . "${SS_BLOCKLIST_FILE}" 2>/dev/null)"
fi
if [ -f "${SS_PREV_BLOCKLIST_GZ}" ]; then
backup_file_size_kb="$(du -k "${SS_PREV_BLOCKLIST_GZ}" 2>/dev/null | awk '{print $1}')"
fi
[ -n "$blocklist_file_size_kb" ] || blocklist_file_size_kb="0"
[ -n "$backup_file_size_kb" ] || backup_file_size_kb="0"
[ -n "$valid_line_count" ] || valid_line_count="0"
printf 'max_blocklist_file_size_kb: %s\n' "$ss_max_blocklist_file_size_kb"
printf 'min_valid_line_count: %s\n' "$ss_min_valid_line_count"
printf 'blocklist_file_size_kb: %s\n' "$blocklist_file_size_kb"
printf 'backup_blocklist_file_size_kb: %s\n' "$backup_file_size_kb"
printf 'valid_line_count: %s\n' "$valid_line_count"
}
statistics() {
if [ -s "${SS_STATISTICS_JSON_FILE}" ]; then
cat "${SS_STATISTICS_JSON_FILE}"
else
printf '%s\n' '{"schema":{"name":"safeshield.statistics","version":3},"volatile":true,"generation_id":"","snapshot_seq":0,"totals":{"queries":0,"blocked":0},"hourly":[],"devices":[]}'
fi
}
version() {
printf '%s\n' "$PKG_VERSION"
}
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
set -e
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
PKG_NAME='safeshield'
export PKG_NAME
# Initialize SafeShield identity once during first boot/package install.
# The script is also safe to run repeatedly because identity-init never
# regenerates an existing identity.env.
if [ -r /lib/functions.sh ]; then
# shellcheck disable=SC1091
. /lib/functions.sh
fi
if [ -r /lib/functions/system.sh ]; then
# shellcheck disable=SC1091
. /lib/functions/system.sh
fi
if [ -r /usr/lib/safeshield/utils.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/utils.sh
fi
if [ -r /usr/lib/safeshield/log.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/log.sh
fi
if [ -r /usr/lib/safeshield/status.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/status.sh
fi
if [ -r /usr/lib/safeshield/config.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/config.sh
ss_load_config >/dev/null 2>&1 || true
fi
if [ -r /usr/lib/safeshield/blocklist.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/blocklist.sh
fi
if command -v ss_sync_detected_device_config >/dev/null 2>&1; then
ss_sync_detected_device_config >/dev/null 2>&1 || true
fi
if [ -r /usr/lib/safeshield/identity.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/identity.sh
model="$(ss_detect_device_model 2>/dev/null || cat /tmp/sysinfo/model 2>/dev/null || echo OpenWrt)"
arch="$(ss_detect_device_arch 2>/dev/null || uname -m 2>/dev/null || echo unknown)"
ss_identity_ensure "$model" "$arch" >/dev/null 2>&1 || true
fi
exit 0
@@ -0,0 +1,7 @@
/etc/safeshield/identity.env
/etc/safeshield/allowlist
/etc/safeshield/blocklist
/etc/safeshield/statistics-state.tsv
/etc/safeshield/statistics-journal.tsv
/etc/safeshield/statistics-sequence.tsv
/etc/config/safeshield
File diff suppressed because it is too large. Load diff
+128
View File
@@ -0,0 +1,128 @@
# shellcheck shell=ash
# This file defines globals consumed by other sourced scripts.
# shellcheck disable=SC2034
ss_enabled="0"
ss_verbosity="2"
ss_license_key=""
ss_device_vendor=""
ss_device_model=""
ss_device_arch=""
ss_device_memory_mb=""
ss_apply_local_overrides="1"
ss_max_blocklist_file_size_kb="30000"
ss_min_valid_line_count="3000"
ss_compress_blocklist="0"
ss_initial_dnsmasq_restart="0"
ss_download_timeout="10"
ss_download_retry="3"
ss_pause_timeout="20"
ss_boot_start_delay_s="30"
ss_dnsmasq_sanity_check="1"
ss_statistics_enabled="1"
ss_statistics_snapshot_interval_s="60"
ss_statistics_retention_hours="168"
ss_valid_line_count="0"
ss_config_get() {
local section="$1"
local option="$2"
local default="$3"
local value
config_get value "$section" "$option"
if [ -n "$value" ]; then
printf '%s' "$value"
else
printf '%s' "$default"
fi
}
ss_validate_bool() {
local var_name="$1"
local default="$2"
local warning_code="$3"
local value
eval "value=\${$var_name}"
case "$value" in
0 | 1) return 0 ;;
esac
log_warn "Invalid ${var_name} '${value}', using default ${default}"
ss_status_add_warning "$warning_code"
eval "$var_name=\$default"
}
ss_validate_int() {
local var_name="$1"
local default="$2"
local warning_code="$3"
local value
eval "value=\${$var_name}"
if ! is_valid_integer "$value"; then
log_warn "Invalid ${var_name} '${value}', using default ${default}"
ss_status_add_warning "$warning_code"
eval "$var_name=\$default"
fi
}
ss_validate_config() {
ss_validate_int ss_download_timeout 10 invalid_download_timeout
ss_validate_int ss_download_retry 3 invalid_download_retry
ss_validate_int ss_max_blocklist_file_size_kb 30000 invalid_max_blocklist_file_size_kb
ss_validate_int ss_min_valid_line_count 3000 invalid_min_valid_line_count
ss_validate_int ss_pause_timeout 20 invalid_pause_timeout
ss_validate_int ss_boot_start_delay_s 30 invalid_boot_start_delay_s
ss_validate_int ss_statistics_snapshot_interval_s 60 invalid_statistics_snapshot_interval_s
ss_validate_int ss_statistics_retention_hours 168 invalid_statistics_retention_hours
ss_validate_bool ss_compress_blocklist 0 invalid_compress_blocklist
ss_validate_bool ss_initial_dnsmasq_restart 0 invalid_initial_dnsmasq_restart
ss_validate_bool ss_dnsmasq_sanity_check 1 invalid_dnsmasq_sanity_check
ss_validate_bool ss_apply_local_overrides 1 invalid_apply_local_overrides
ss_validate_bool ss_statistics_enabled 1 invalid_statistics_enabled
if [ "$ss_statistics_snapshot_interval_s" -lt 10 ] 2>/dev/null || [ "$ss_statistics_snapshot_interval_s" -gt 3600 ] 2>/dev/null; then
log_warn "statistics_snapshot_interval_s must be between 10 and 3600 seconds, using 60"
ss_status_add_warning "invalid_statistics_snapshot_interval_s"
ss_statistics_snapshot_interval_s=60
fi
if [ "$ss_statistics_retention_hours" -lt 1 ] 2>/dev/null || [ "$ss_statistics_retention_hours" -gt 168 ] 2>/dev/null; then
log_warn "statistics_retention_hours must be between 1 and 168, using 168"
ss_status_add_warning "invalid_statistics_retention_hours"
ss_statistics_retention_hours=168
fi
}
ss_load_config() {
config_load safeshield || return 1
ss_enabled="$(ss_config_get config enabled 0)"
ss_verbosity="$(ss_config_get config verbosity 2)"
ss_license_key="$(ss_config_get config license_key '')"
ss_device_vendor="$(ss_config_get config device_vendor '')"
ss_device_model="$(ss_config_get config device_model '')"
ss_device_arch="$(ss_config_get config device_arch '')"
ss_device_memory_mb="$(ss_config_get config device_memory_mb '')"
ss_apply_local_overrides="$(ss_config_get config apply_local_overrides 1)"
ss_max_blocklist_file_size_kb="$(ss_config_get config max_blocklist_file_size_kb 30000)"
ss_min_valid_line_count="$(ss_config_get config min_valid_line_count 3000)"
ss_compress_blocklist="$(ss_config_get config compress_blocklist 0)"
ss_initial_dnsmasq_restart="$(ss_config_get config initial_dnsmasq_restart 0)"
ss_download_timeout="$(ss_config_get config download_timeout 10)"
ss_download_retry="$(ss_config_get config download_retry 3)"
ss_pause_timeout="$(ss_config_get config pause_timeout 20)"
ss_boot_start_delay_s="$(ss_config_get config boot_start_delay_s 30)"
ss_dnsmasq_sanity_check="$(ss_config_get config dnsmasq_sanity_check 1)"
ss_statistics_enabled="$(ss_config_get config statistics_enabled 1)"
ss_statistics_snapshot_interval_s="$(ss_config_get config statistics_snapshot_interval_s 60)"
ss_statistics_retention_hours="$(ss_config_get config statistics_retention_hours 168)"
ss_validate_config
}
+822
View File
@@ -0,0 +1,822 @@
# shellcheck shell=ash
# This file defines globals consumed by other sourced scripts.
# shellcheck disable=SC2034
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
if [ -z "${PKG_NAME:-}" ]; then
PKG_NAME='safeshield'
fi
export PKG_NAME
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/share/libubox/jshn.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/utils.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/status.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/log.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/config.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/identity.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/dns.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/blocklist.sh"
readonly SS_TMP_DIR="/tmp/safeshield"
readonly SS_DNSMASQ_DIR="/tmp/dnsmasq.d"
readonly SS_BLOCKLIST_FILE="${SS_DNSMASQ_DIR}/safeshield.blocklist"
readonly SS_INACTIVE_BLOCKLIST_FILE="${SS_TMP_DIR}/inactive.blocklist"
readonly SS_LOCAL_ALLOWLIST_FILE="/etc/safeshield/allowlist"
readonly SS_LOCAL_BLOCKLIST_FILE="/etc/safeshield/blocklist"
readonly SS_IDENTITY_DIR="/etc/safeshield"
readonly SS_IDENTITY_FILE="${SS_IDENTITY_DIR}/identity.env"
readonly SS_API_PAYLOAD="${SS_TMP_DIR}/resolve-request.json"
readonly SS_API_RESPONSE="${SS_TMP_DIR}/resolve-response.json"
readonly SS_RESOLVED_SOURCES="${SS_TMP_DIR}/resolved-sources.tsv"
readonly SS_ARTIFACT_CACHE_STATE="${SS_TMP_DIR}/artifact-sources.state"
readonly SS_MAX_ARTIFACT_SOURCES=16
readonly SS_PREV_BLOCKLIST_GZ="/tmp/safeshield.prev.blocklist.gz"
readonly SS_RUNTIME_OUT="${SS_TMP_DIR}/runtime.out"
readonly SS_REFRESH_LOCK='/var/lock/safeshield-refresh.lock'
readonly SS_REFRESH_LOCK_FD=307
readonly SS_LOCAL_APPLY_STATE="${SS_TMP_DIR}/local-apply.state"
readonly SS_STATISTICS_DIR="${SS_TMP_DIR}/statistics"
readonly SS_STATISTICS_STATE_FILE="${SS_STATISTICS_DIR}/state.tsv"
readonly SS_STATISTICS_JSON_FILE="${SS_STATISTICS_DIR}/statistics.json"
readonly SS_STATISTICS_UPLOAD_JSON_FILE="${SS_STATISTICS_DIR}/upload.json"
readonly SS_STATISTICS_UPLOAD_CREDENTIALS_FILE="${SS_STATISTICS_DIR}/upload.credentials"
readonly SS_STATISTICS_UPLOAD_ENTITLEMENT_FILE="${SS_STATISTICS_DIR}/upload.entitlement"
readonly SS_STATISTICS_UPLOAD_PENDING_FILE="${SS_STATISTICS_DIR}/upload.pending.json"
readonly SS_STATISTICS_UPLOAD_PENDING_META_FILE="${SS_STATISTICS_DIR}/upload.pending.meta"
readonly SS_STATISTICS_UPLOAD_STATE_FILE="${SS_STATISTICS_DIR}/upload.state"
readonly SS_STATISTICS_DNSMASQ_CONF="${SS_DNSMASQ_DIR}/safeshield.statistics.conf"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/statistics.sh"
ss_should_terminate="${ss_should_terminate:-0}"
ss_should_stop() {
[ "${ss_should_terminate:-0}" -eq 1 ]
}
ss_abort_refresh() {
log_warn "Termination requested, aborting current refresh"
ss_status_add_warning "refresh_terminated"
ss_status_set last_result "terminated"
ss_refresh_lock_close
return 130
}
ss_restore_and_restart() {
ss_restore_previous_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
}
ss_ensure_dnsmasq_confdir() {
local changed=0
mkdir -p "${SS_DNSMASQ_DIR}" || {
log_error "Failed to create dnsmasq confdir: ${SS_DNSMASQ_DIR}"
return 1
}
if ss_check_dnsmasq_confdir; then
return 0
fi
uci -q get dhcp.@dnsmasq[0] >/dev/null || {
log_error "dnsmasq UCI section not found: dhcp.@dnsmasq[0]"
return 1
}
log_warn "dnsmasq confdir is not set to ${SS_DNSMASQ_DIR}, attempting automatic setup"
if ! uci show dhcp | grep -Fq ".confdir='${SS_DNSMASQ_DIR}'"; then
uci add_list dhcp.@dnsmasq[0].confdir="${SS_DNSMASQ_DIR}" || {
log_error "Failed to add dhcp.@dnsmasq[0].confdir=${SS_DNSMASQ_DIR}"
return 1
}
changed=1
fi
if [ "${changed}" -eq 1 ]; then
uci commit dhcp || {
log_error "Failed to commit dhcp config"
return 1
}
log_info "Restarting dnsmasq after updating confdir"
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after configuring confdir"
return 1
}
fi
ss_check_dnsmasq_confdir || {
log_error "dnsmasq confdir verification failed after automatic setup"
return 1
}
return 0
}
ss_sync_blocklist_status() {
local file_size_kb valid_line_count
if [ ! -f "${SS_BLOCKLIST_FILE}" ]; then
ss_status_set blocklist_installed "0"
ss_status_set blocklist_file_size_kb "0"
ss_status_set valid_line_count "0"
ss_status_set blocklist_verification_ok "0"
ss_status_set health_blocklist_verify ""
return 0
fi
file_size_kb="$(du -k "${SS_BLOCKLIST_FILE}" 2>/dev/null | awk '{print $1}')"
valid_line_count="$(grep -c . "${SS_BLOCKLIST_FILE}" 2>/dev/null)"
[ -n "$file_size_kb" ] || file_size_kb="0"
[ -n "$valid_line_count" ] || valid_line_count="0"
ss_status_set blocklist_installed "1"
ss_status_set blocklist_file_size_kb "$file_size_kb"
ss_status_set valid_line_count "$valid_line_count"
}
ss_stage_active_blocklist() {
[ -f "${SS_BLOCKLIST_FILE}" ] || return 0
rm -f "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_sync_path "${SS_BLOCKLIST_FILE}"
mv -f "${SS_BLOCKLIST_FILE}" "${SS_INACTIVE_BLOCKLIST_FILE}" || return 1
ss_sync_path "${SS_DNSMASQ_DIR}"
ss_sync_path "${SS_TMP_DIR}"
return 0
}
ss_restore_staged_blocklist() {
[ -f "${SS_INACTIVE_BLOCKLIST_FILE}" ] || return 1
mv -f "${SS_INACTIVE_BLOCKLIST_FILE}" "${SS_BLOCKLIST_FILE}" || return 1
ss_sync_path "${SS_DNSMASQ_DIR}"
return 0
}
ss_runtime_deactivate_blocklist() {
local rc
ss_mkdirs || return 1
if [ ! -f "${SS_BLOCKLIST_FILE}" ]; then
ss_sync_blocklist_status
return 0
fi
log_info "Deactivating SafeShield blocklist"
ss_stage_active_blocklist || {
log_error "Failed to stage active blocklist for deactivation"
return 1
}
dnsmasq_restart
rc=$?
if [ "$rc" -ne 0 ]; then
log_error "dnsmasq restart failed while deactivating SafeShield; restoring blocklist"
ss_restore_staged_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
ss_sync_blocklist_status
return "$rc"
fi
ss_sync_blocklist_status
ss_status_set health_dnsmasq_final_restart "1"
log_ok "SafeShield blocklist deactivated"
return 0
}
ss_runtime_activate_blocklist() {
local rc
ss_mkdirs || return 1
if [ ! -f "${SS_BLOCKLIST_FILE}" ] && [ -f "${SS_INACTIVE_BLOCKLIST_FILE}" ]; then
log_info "Restoring staged SafeShield blocklist"
ss_restore_staged_blocklist || {
log_error "Failed to restore staged SafeShield blocklist"
return 1
}
fi
if [ ! -f "${SS_BLOCKLIST_FILE}" ]; then
ss_sync_blocklist_status
return 0
fi
ss_ensure_dnsmasq_confdir || {
log_error "dnsmasq confdir is unavailable while activating SafeShield"
ss_stage_active_blocklist >/dev/null 2>&1 || true
ss_sync_blocklist_status
return 1
}
dnsmasq_restart
rc=$?
if [ "$rc" -ne 0 ]; then
log_error "dnsmasq restart failed while activating SafeShield"
ss_stage_active_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
ss_sync_blocklist_status
return "$rc"
fi
check_blocklist_applied_multi_with_stats 5 3
rc=$?
if [ "$rc" -ne 0 ]; then
log_error "SafeShield blocklist is not active in dnsmasq"
ss_status_set health_blocklist_verify "0"
ss_status_set blocklist_verification_ok "0"
ss_stage_active_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
ss_sync_blocklist_status
return "$rc"
fi
rm -f "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_sync_blocklist_status
ss_status_set health_dnsmasq_final_restart "1"
ss_status_set health_blocklist_verify "1"
ss_status_set blocklist_verification_ok "1"
log_ok "SafeShield blocklist active in dnsmasq"
return 0
}
ss_local_rules_fingerprint_from_tmp() {
{
printf '%s\n' '[allow]'
[ -f "${SS_TMP_DIR}/local.allow.txt" ] && cat "${SS_TMP_DIR}/local.allow.txt"
printf '%s\n' '[block]'
[ -f "${SS_TMP_DIR}/local.block.txt" ] && cat "${SS_TMP_DIR}/local.block.txt"
} | cksum | awk '{ print $1 ":" $2 }'
}
ss_local_rules_state_read() {
[ -s "${SS_LOCAL_APPLY_STATE}" ] || return 1
head -n 1 "${SS_LOCAL_APPLY_STATE}" 2>/dev/null
}
ss_local_rules_state_write() {
local fingerprint="$1"
local tmp="${SS_LOCAL_APPLY_STATE}.tmp.$$"
[ -n "$fingerprint" ] || return 1
printf '%s\n' "$fingerprint" >"$tmp" || return 1
ss_sync_path "$tmp"
mv -f "$tmp" "${SS_LOCAL_APPLY_STATE}" || {
rm -f "$tmp"
return 1
}
ss_sync_path "${SS_TMP_DIR}"
return 0
}
ss_prepare_local_rule_files() {
if [ "${ss_apply_local_overrides}" = "1" ]; then
ss_build_local_allowlist || return 1
ss_build_local_blocklist || return 1
else
: >"${SS_TMP_DIR}/local.allow.txt" || return 1
: >"${SS_TMP_DIR}/local.block.txt" || return 1
fi
}
ss_mark_local_apply_failure() {
local code="$1"
ss_status_set status "error"
ss_status_set stage "local_apply_failed"
ss_status_set_now last_local_apply_failure
ss_status_set last_result "error"
ss_status_set last_error_code "$code"
ss_status_add_error "$code"
}
ss_mark_local_apply_success() {
ss_status_set status "ready"
ss_status_set stage "done"
ss_status_set_now last_local_apply
ss_status_set last_result "success"
ss_status_set last_error_code ""
}
safeshield_apply_local_rules() {
local rc fingerprint applied_fingerprint
local wait_timeout=120
ss_load_config || return 1
ss_mkdirs || return 1
if [ "${ss_enabled}" != "1" ]; then
log_warn "Local rule apply skipped because SafeShield is disabled"
return 0
fi
if [ "${ss_apply_local_overrides}" != "1" ]; then
log_warn "Local rule apply skipped because apply_local_overrides is disabled"
return 0
fi
# Short engine-side debounce lets rapid rule edits converge before the first
# worker acquires the shared refresh lock. Later workers will fingerprint the
# same normalized state and exit without another dnsmasq restart.
sleep 1
log_info "Waiting for SafeShield refresh lock before applying local rules"
ss_refresh_lock_open_wait "$wait_timeout"
rc=$?
case "$rc" in
0) ;;
130) return 130 ;;
*)
log_error "Timed out waiting for SafeShield refresh lock"
ss_mark_local_apply_failure "local_apply_lock_timeout"
return 1
;;
esac
# Configuration may have changed while waiting for a full refresh.
ss_load_config || {
ss_mark_local_apply_failure "config_load_failed"
ss_refresh_lock_close
return 1
}
if [ "${ss_enabled}" != "1" ]; then
ss_refresh_lock_close
return 0
fi
if [ "${ss_apply_local_overrides}" != "1" ]; then
ss_refresh_lock_close
return 0
fi
# Normalized Hub sources are retained after a successful refresh. If the
# complete cached source set is unavailable (for example after reboot), fall
# back to one full refresh rather than reconstructing the Hub/local boundary
# from the active dnsmasq file.
if ! ss_cached_api_sources_available; then
log_warn "Cached Hub artifact is unavailable; falling back to full refresh"
ss_refresh_lock_close
safeshield_force_download
rc=$?
if [ "$rc" -ne 0 ]; then
ss_status_set_now last_local_apply_failure
fi
return "$rc"
fi
ss_status_set status "running"
ss_status_set stage "local_apply"
ss_status_set last_result "running"
ss_status_set last_error_code ""
ss_prepare_local_rule_files || {
log_error "Failed to normalize local SafeShield rules"
ss_mark_local_apply_failure "local_rules_build_failed"
ss_refresh_lock_close
return 1
}
fingerprint="$(ss_local_rules_fingerprint_from_tmp)"
applied_fingerprint="$(ss_local_rules_state_read 2>/dev/null || true)"
# Multiple rapid rule mutations may spawn several background apply workers.
# Once the newest normalized local state is active, later workers can exit
# without another dnsmasq restart.
if [ -n "$fingerprint" ] && [ "$fingerprint" = "$applied_fingerprint" ] && [ -f "${SS_BLOCKLIST_FILE}" ]; then
log_info "Local SafeShield rules are already applied; skipping duplicate apply"
ss_status_set status "ready"
ss_status_set stage "done"
ss_status_set last_result "success"
ss_status_set last_error_code ""
ss_refresh_lock_close
return 0
fi
ss_require_supported_dnsmasq || {
ss_mark_local_apply_failure "$(ss_dnsmasq_check_error)"
ss_refresh_lock_close
return 1
}
ss_ensure_dnsmasq_confdir || {
ss_status_set health_dnsmasq_confdir "0"
ss_mark_local_apply_failure "dnsmasq_confdir_not_set"
ss_refresh_lock_close
return 1
}
ss_status_set health_dnsmasq_confdir "1"
ss_export_existing_blocklist >/dev/null 2>&1 || true
if [ -f "${SS_PREV_BLOCKLIST_GZ}" ]; then
ss_status_set blocklist_backup_available "1"
fi
ss_status_set stage "local_merge"
ss_merge_lists
rc=$?
case "$rc" in
0) ;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "Failed to merge cached Hub artifact with local rules"
ss_mark_local_apply_failure "local_merge_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
ss_status_set stage "local_restart_dnsmasq"
dnsmasq_restart
rc=$?
case "$rc" in
0)
ss_status_set health_dnsmasq_final_restart "1"
;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "dnsmasq restart failed after local rule merge"
ss_status_set health_dnsmasq_final_restart "0"
ss_mark_local_apply_failure "local_dnsmasq_restart_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
ss_status_set stage "local_runtime_check"
check_dns_runtime
rc=$?
case "$rc" in
0)
ss_status_set health_dns_runtime "1"
;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "DNS runtime check failed after local rule merge"
ss_status_set health_dns_runtime "0"
ss_mark_local_apply_failure "local_dns_runtime_check_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
ss_status_set stage "local_blocklist_verify"
check_blocklist_applied_multi_with_stats 5 3
rc=$?
case "$rc" in
0)
ss_status_set health_blocklist_verify "1"
ss_status_set blocklist_verification_ok "1"
ss_local_rules_state_write "$fingerprint" || true
ss_mark_local_apply_success
rm -f "${SS_PREV_BLOCKLIST_GZ}" "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_status_set blocklist_backup_available "0"
ss_refresh_lock_close
log_ok "Local SafeShield rules applied using cached Hub artifact"
return 0
;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "Local blocklist verification failed; restoring previous blocklist"
ss_status_set health_blocklist_verify "0"
ss_status_set blocklist_verification_ok "0"
ss_mark_local_apply_failure "local_blocklist_verification_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
}
safeshield_force_download() {
local rc refresh_interval failure_code
if ! ss_refresh_lock_open; then
log_warn "Another refresh is already running, skipping"
return 0
fi
ss_status_prepare_refresh
ss_status_set status "running"
ss_status_set stage "init"
ss_status_set_now last_attempt
ss_status_set last_result "running"
ss_status_set last_error_code ""
ss_load_config || {
ss_status_mark_failure "config_load_failed"
ss_refresh_lock_close
return 1
}
refresh_interval="$(ss_config_get config refresh_interval_s 28800)"
if ! is_valid_integer "$refresh_interval" || [ "$refresh_interval" -le 0 ] 2>/dev/null; then
refresh_interval=28800
fi
if [ "${ss_enabled}" != "1" ]; then
log_warn "SafeShield refresh skipped because the service is disabled"
ss_status_set status "disabled"
ss_status_set stage "disabled"
ss_status_set last_result "disabled"
ss_status_set next_refresh_at "0"
ss_refresh_lock_close
return 0
fi
ss_sync_detected_device_config || true
ss_identity_ensure "$(ss_detect_device_model 2>/dev/null || echo OpenWrt)" "$(ss_detect_device_arch 2>/dev/null || echo unknown)" || {
ss_status_mark_failure "identity_init_failed"
ss_refresh_lock_close
return 1
}
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_mkdirs || {
ss_status_mark_failure "mkdir_failed"
ss_refresh_lock_close
return 1
}
ss_clean_tmp
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_require_supported_dnsmasq || {
ss_status_mark_failure "$(ss_dnsmasq_check_error)"
ss_refresh_lock_close
return 1
}
ss_ensure_dnsmasq_confdir || {
log_error "dnsmasq confdir could not be configured automatically for ${SS_DNSMASQ_DIR}"
ss_status_set health_dnsmasq_confdir "0"
ss_status_mark_failure "dnsmasq_confdir_not_set"
ss_refresh_lock_close
return 1
}
ss_status_set health_dnsmasq_confdir "1"
ss_export_existing_blocklist >/dev/null 2>&1 || true
if [ -f "${SS_PREV_BLOCKLIST_GZ}" ]; then
ss_status_set blocklist_backup_available "1"
else
ss_status_set blocklist_backup_available "0"
fi
if [ "${ss_initial_dnsmasq_restart}" = "1" ]; then
dnsmasq_restart
rc=$?
case "$rc" in
0)
ss_status_set health_dnsmasq_initial_restart "1"
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "Initial dnsmasq restart failed"
ss_status_set health_dnsmasq_initial_restart "0"
ss_status_mark_failure "initial_dnsmasq_restart_failed"
ss_refresh_lock_close
return 1
;;
esac
else
ss_status_set health_dnsmasq_initial_restart ""
fi
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "resolve_api"
ss_resolve_artifact
rc=$?
case "$rc" in
0) ;;
130)
ss_abort_refresh
return $?
;;
*)
failure_code="$(ss_resolve_error_code)"
ss_status_mark_failure "$failure_code"
if [ "$failure_code" != "safeshield_upgrade_required" ]; then
ss_restore_and_restart
fi
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "download_artifact"
ss_download_api_artifacts
rc=$?
case "$rc" in
0) ;;
130)
ss_abort_refresh
return $?
;;
*)
failure_code="$(ss_artifact_download_error_code)"
ss_status_mark_failure "$failure_code"
if [ "$failure_code" != "safeshield_upgrade_required" ]; then
ss_restore_and_restart
fi
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "local_overrides"
ss_prepare_local_rule_files || {
log_error "Failed to build allowlist"
ss_status_mark_failure "allowlist_build_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
}
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "merge"
ss_merge_lists
rc=$?
case "$rc" in
0) ;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "Failed to merge API artifact with local overrides"
ss_status_mark_failure "merge_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "install"
ss_install_blocklist || {
log_error "Failed to install blocklist"
ss_status_mark_failure "install_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
}
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "restart_dnsmasq"
dnsmasq_restart
rc=$?
case "$rc" in
0)
ss_status_set health_dnsmasq_final_restart "1"
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "dnsmasq restart failed"
ss_status_set health_dnsmasq_final_restart "0"
ss_status_mark_failure "dnsmasq_restart_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "runtime_check"
check_dns_runtime
rc=$?
case "$rc" in
0)
ss_status_set health_dns_runtime "1"
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "DNS runtime check failed, restoring previous blocklist"
ss_status_set health_dns_runtime "0"
ss_status_mark_failure "dns_runtime_check_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "blocklist_verify"
check_blocklist_applied_multi_with_stats 5 3
rc=$?
case "$rc" in
0)
log_ok "SafeShield applied successfully"
ss_status_set health_blocklist_verify "1"
ss_status_set blocklist_verification_ok "1"
ss_local_rules_state_write "$(ss_local_rules_fingerprint_from_tmp)" || true
ss_status_set_now last_local_apply
ss_status_mark_success "$refresh_interval"
rm -f "${SS_PREV_BLOCKLIST_GZ}" "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_status_set blocklist_backup_available "0"
ss_refresh_lock_close
return 0
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "Blocklist verification failed, restoring previous blocklist"
ss_status_set health_blocklist_verify "0"
ss_status_set blocklist_verification_ok "0"
ss_status_mark_failure "blocklist_verification_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
}
+153
View File
@@ -0,0 +1,153 @@
# shellcheck shell=ash
readonly SS_MIN_DNSMASQ_VERSION='2.93'
_ss_dnsmasq_check_error=''
ss_dnsmasq_check_error() {
printf '%s\n' "${_ss_dnsmasq_check_error}"
}
ss_dnsmasq_version() {
dnsmasq --version 2>/dev/null | awk '
NR == 1 && $1 == "Dnsmasq" && $2 == "version" {
version = $3
sub(/[^0-9.].*$/, "", version)
print version
exit
}
'
}
ss_dnsmasq_safeshield_block_supported() {
dnsmasq --test --conf-file=/dev/null --safeshield-block=/safeshield.invalid/ >/dev/null 2>&1
}
ss_require_supported_dnsmasq() {
local current_version
_ss_dnsmasq_check_error=''
ss_status_set dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION}"
if ! check_dnsmasq_binary; then
_ss_dnsmasq_check_error='dnsmasq_binary_not_found'
ss_status_set dnsmasq_version ''
ss_status_set health_dnsmasq_binary '0'
ss_status_set health_dnsmasq_version '0'
ss_status_set health_dnsmasq_features '0'
log_error 'dnsmasq binary not found'
return 1
fi
ss_status_set health_dnsmasq_binary '1'
current_version="$(ss_dnsmasq_version)"
ss_status_set dnsmasq_version "${current_version}"
if [ -z "${current_version}" ]; then
_ss_dnsmasq_check_error='dnsmasq_version_unknown'
ss_status_set health_dnsmasq_version '0'
ss_status_set health_dnsmasq_features '0'
log_error 'Unable to determine dnsmasq version'
return 1
fi
if ! is_greater_equal "${current_version}" "${SS_MIN_DNSMASQ_VERSION}"; then
_ss_dnsmasq_check_error='dnsmasq_version_unsupported'
ss_status_set health_dnsmasq_version '0'
ss_status_set health_dnsmasq_features '0'
log_error "Unsupported dnsmasq version ${current_version}; SafeShield requires ${SS_MIN_DNSMASQ_VERSION} or later"
return 1
fi
ss_status_set health_dnsmasq_version '1'
if ss_dnsmasq_safeshield_block_supported; then
ss_status_set health_dnsmasq_features '1'
else
# The SafeShield dnsmasq patch is optional for basic DNS blocking. Keep
# protection available with legacy address=/domain/# rules, while the
# statistics runtime disables itself because cumulative counters are absent.
ss_status_set health_dnsmasq_features '0'
fi
return 0
}
ss_mkdirs() {
mkdir -p "${SS_TMP_DIR}" "${SS_DNSMASQ_DIR}" "${SS_STATISTICS_DIR}"
}
ss_clean_tmp() {
rm -f \
"${SS_TMP_DIR}"/*.txt \
"${SS_TMP_DIR}"/*.raw \
"${SS_TMP_DIR}"/*.filtered \
"${SS_TMP_DIR}"/*.domains \
"${SS_TMP_DIR}"/*.json \
"${SS_RESOLVED_SOURCES}" \
"${SS_ARTIFACT_CACHE_STATE}" \
"${SS_RUNTIME_OUT}" \
2>/dev/null
}
ss_check_dnsmasq_confdir() {
local confdir
confdir="$(uci -q get dhcp.@dnsmasq[0].confdir 2>/dev/null)"
[ "$confdir" = "${SS_DNSMASQ_DIR}" ]
}
check_dnsmasq_binary() {
command_exists dnsmasq
}
check_dnsmasq_process() {
pidof dnsmasq >/dev/null 2>&1
}
check_dns_runtime() {
local domain i
check_dnsmasq_process || return 1
for domain in google.com cloudflare.com microsoft.com; do
for i in 1 2 3 4 5 6 7 8 9 10; do
ss_should_stop && return 130
if nslookup "$domain" 127.0.0.1 >"${SS_RUNTIME_OUT}" 2>/dev/null; then
if ! grep -Eq '^Address: *(0\.0\.0\.0|::)$' "${SS_RUNTIME_OUT}"; then
rm -f "${SS_RUNTIME_OUT}"
return 0
fi
fi
ss_should_stop && return 130
sleep 1
done
done
rm -f "${SS_RUNTIME_OUT}"
return 1
}
dnsmasq_restart() {
local i
log_info "Restarting dnsmasq"
/etc/init.d/dnsmasq restart >/dev/null 2>&1 || return 1
# shellcheck disable=SC2034
for i in 1 2 3 4 5 6 7 8 9 10 \
11 12 13 14 15 16 17 18 19 20 \
21 22 23 24 25 26 27 28 29 30; do
ss_should_stop && return 130
if check_dnsmasq_process && nslookup localhost 127.0.0.1 >/dev/null 2>&1; then
log_ok "dnsmasq restart done"
return 0
fi
sleep 1
done
log_error "dnsmasq did not become ready after restart"
return 1
}
+532
View File
@@ -0,0 +1,532 @@
# shellcheck shell=ash
SS_IDENTITY_DIR="${SS_IDENTITY_DIR:-/etc/safeshield}"
SS_IDENTITY_FILE="${SS_IDENTITY_FILE:-${SS_IDENTITY_DIR}/identity.env}"
SS_IDENTITY_VERSION="1"
SS_PHYSICAL_FINGERPRINT=""
SS_FINGERPRINT_VERSION="1"
SS_IDENTITY_PROVIDER=""
SS_IDENTITY_SOURCE=""
SS_IDENTITY_STRENGTH=""
SS_IDENTITY_PROFILE=""
SS_IDENTITY_VALUE_SHA256=""
SS_INSTALLATION_ID=""
SS_INSTALLATION_SECRET=""
SS_IDENTITY_CREATED_AT=""
SS_IDENTITY_UPDATED_AT=""
SS_DEVICE_CODE=""
SS_DEVICE_CODE_SOURCE="unknown"
SS_SMARTSAFEHUB_FIRMWARE_JSON="${SS_SMARTSAFEHUB_FIRMWARE_JSON:-/usr/share/smartsafehub/firmware.json}"
ss_identity_log_warn() {
if command -v log_warn >/dev/null 2>&1; then
log_warn "$1"
else
logger -t safeshield "$1" 2>/dev/null || true
fi
}
ss_identity_log_info() {
if command -v log_info >/dev/null 2>&1; then
log_info "$1"
else
logger -t safeshield "$1" 2>/dev/null || true
fi
}
ss_identity_command_exists() {
command -v "$1" >/dev/null 2>&1
}
ss_identity_sha256() {
local value="$1"
if ! ss_identity_command_exists sha256sum; then
ss_identity_log_warn "sha256sum is required for identity hashing"
return 1
fi
printf '%s' "$value" | sha256sum | awk '{print $1}'
}
ss_identity_random_hex_32() {
if [ -r /dev/urandom ] && ss_identity_command_exists hexdump; then
hexdump -n 32 -e '32/1 "%02x"' /dev/urandom 2>/dev/null && return 0
fi
ss_identity_sha256 "fallback-random|$(date +%s)|$$|$(cat /proc/uptime 2>/dev/null)"
}
ss_identity_uuid() {
local value=""
value="$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '\r\n' || true)"
if [ -n "$value" ]; then
printf '%s' "$value"
return 0
fi
value="$(ss_identity_random_hex_32)"
printf '%s-%s-%s-%s-%s' \
"$(printf '%s' "$value" | cut -c 1-8)" \
"$(printf '%s' "$value" | cut -c 9-12)" \
"$(printf '%s' "$value" | cut -c 13-16)" \
"$(printf '%s' "$value" | cut -c 17-20)" \
"$(printf '%s' "$value" | cut -c 21-32)"
}
ss_identity_read_file_one_line() {
local path="$1"
[ -r "$path" ] || return 1
tr -d '\000\r\n' <"$path" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | head -c 256
}
ss_identity_normalize_mac() {
printf '%s' "$1" |
tr 'A-F' 'a-f' |
sed -n 's/.*\([0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]\).*/\1/p' |
head -n 1
}
ss_identity_valid_mac() {
local mac="$1"
case "$mac" in
'' | 00:00:00:00:00:00 | ff:ff:ff:ff:ff:ff)
return 1
;;
esac
printf '%s' "$mac" | grep -Eq '^[0-9a-f]{2}(:[0-9a-f]{2}){5}$'
}
ss_identity_valid_sha256() {
printf '%s' "$1" | grep -Eq '^[0-9a-f]{64}$'
}
ss_identity_board_name() {
local value=""
value="$(ss_identity_read_file_one_line /tmp/sysinfo/board_name 2>/dev/null || true)"
[ -n "$value" ] || value="$(ubus call system board 2>/dev/null | jsonfilter -e '@.board_name' 2>/dev/null | head -n 1 || true)"
[ -n "$value" ] || value="unknown"
printf '%s' "$value"
}
ss_identity_valid_device_code() {
printf '%s' "$1" | grep -Eq '^[a-z0-9][a-z0-9._-]{0,63}$'
}
ss_identity_device_code_from_firmware() {
local firmware_json="${SS_SMARTSAFEHUB_FIRMWARE_JSON:-/usr/share/smartsafehub/firmware.json}"
local device_code=""
[ -r "$firmware_json" ] || return 1
ss_identity_command_exists jsonfilter || return 1
device_code="$(jsonfilter -i "$firmware_json" -e '@.device_code' 2>/dev/null | head -n 1 | tr -d '\r\n' || true)"
ss_identity_valid_device_code "$device_code" || return 1
printf '%s' "$device_code"
}
ss_identity_device_code_from_board() {
local board="$1"
case "$board" in
iptime,ax3000sm)
printf '%s' 'iptime-ax3000sm'
;;
glinet,gl-mt300n-v2 | gl.inet,gl-mt300n-v2)
printf '%s' 'gl-mt300n-v2'
;;
xiaomi,mi-router-ax3000t | xiaomi,mi-router-ax3000t-ubootmod)
printf '%s' 'xiaomi-ax3000t'
;;
*)
return 1
;;
esac
}
ss_identity_refresh_device_code() {
local device_code=""
local board=""
device_code="$(ss_identity_device_code_from_firmware 2>/dev/null || true)"
if [ -n "$device_code" ]; then
SS_DEVICE_CODE="$device_code"
SS_DEVICE_CODE_SOURCE="smartsafehub_firmware"
return 0
fi
board="$(ss_identity_board_name)"
device_code="$(ss_identity_device_code_from_board "$board" 2>/dev/null || true)"
if [ -n "$device_code" ]; then
SS_DEVICE_CODE="$device_code"
SS_DEVICE_CODE_SOURCE="board"
return 0
fi
SS_DEVICE_CODE=""
SS_DEVICE_CODE_SOURCE="unknown"
return 0
}
ss_identity_profile_code() {
local board="$1"
local profile=""
case "$board" in
iptime,ax3000sm | *ax3000sm*)
profile="iptime_ax3000sm"
;;
glinet,gl-mt300n-v2 | gl.inet,gl-mt300n-v2 | *gl-mt300n-v2*)
profile="gl_mt300n_v2"
;;
smartsafehub,* | *smartsafehub*)
profile="smartsafehub"
;;
*)
profile="$(printf '%s' "$board" | tr 'A-Z,.-' 'a-z___' | sed 's/[^a-z0-9_]/_/g')"
[ -n "$profile" ] || profile="unknown"
;;
esac
printf '%s' "$profile"
}
ss_identity_mtd_device_by_name() {
local name="$1"
[ -r /proc/mtd ] || return 1
awk -F: -v q="\"${name}\"" 'index($0, q) { print "/dev/" $1; exit }' /proc/mtd
}
ss_identity_hex_to_mac() {
local hex="$1"
printf '%s' "$hex" | sed 's/[^0-9a-fA-F]//g' | tr 'A-F' 'a-f' | sed 's/../&:/g; s/:$//'
}
ss_identity_mtd_get_mac_binary_fallback() {
local part="$1"
local offset="$2"
local dev=""
local hex=""
dev="$(ss_identity_mtd_device_by_name "$part" 2>/dev/null || true)"
[ -n "$dev" ] || return 1
[ -r "$dev" ] || return 1
ss_identity_command_exists dd || return 1
ss_identity_command_exists hexdump || return 1
# ash supports hexadecimal arithmetic, so offsets like 0x4 are accepted.
hex="$(dd if="$dev" bs=1 skip=$((offset)) count=6 2>/dev/null | hexdump -v -e '1/1 "%02x"' 2>/dev/null)"
[ "${#hex}" -eq 12 ] || return 1
ss_identity_hex_to_mac "$hex"
}
ss_identity_mtd_get_mac() {
local part="$1"
local offset="$2"
local mac=""
if ss_identity_command_exists mtd_get_mac_binary; then
mac="$(mtd_get_mac_binary "$part" "$offset" 2>/dev/null || true)"
mac="$(ss_identity_normalize_mac "$mac")"
if ss_identity_valid_mac "$mac"; then
printf '%s' "$mac"
return 0
fi
fi
mac="$(ss_identity_mtd_get_mac_binary_fallback "$part" "$offset" 2>/dev/null || true)"
mac="$(ss_identity_normalize_mac "$mac")"
ss_identity_valid_mac "$mac" || return 1
printf '%s' "$mac"
}
ss_identity_try_mtd_factory_mac() {
local board="$1"
local mac=""
local offset=""
case "$board" in
iptime,ax3000sm | *ax3000sm*)
mac="$(ss_identity_mtd_get_mac Factory 0x4 2>/dev/null || true)"
if ss_identity_valid_mac "$mac"; then
printf '%s|%s' "$mac" "mtd:Factory:0x4:mac"
return 0
fi
mac="$(ss_identity_mtd_get_mac factory 0x4 2>/dev/null || true)"
if ss_identity_valid_mac "$mac"; then
printf '%s|%s' "$mac" "mtd:factory:0x4:mac"
return 0
fi
;;
glinet,gl-mt300n-v2 | gl.inet,gl-mt300n-v2 | *gl-mt300n-v2*)
# Must be verified on the real GL-MT300N-V2 before production.
for offset in 0x4 0x28 0x2e; do
mac="$(ss_identity_mtd_get_mac factory "$offset" 2>/dev/null || true)"
if ss_identity_valid_mac "$mac"; then
printf '%s|%s' "$mac" "mtd:factory:${offset}:mac"
return 0
fi
done
;;
esac
return 1
}
ss_identity_try_factory_device_id() {
local value=""
local path=""
# Future SmartSafeHub hardware can expose a factory-programmed device_id
# through DTS/NVMEM, sysfs, or a small platform driver.
for path in \
/sys/firmware/smartsafehub/device_id \
/sys/firmware/devicetree/base/smartsafehub,device-id \
/proc/device-tree/smartsafehub,device-id; do
value="$(ss_identity_read_file_one_line "$path" 2>/dev/null || true)"
case "$value" in
'' | *[!A-Za-z0-9._:-]*) ;;
*)
printf '%s|%s' "$value" "path:${path}"
return 0
;;
esac
done
return 1
}
ss_identity_detect_primary_mac() {
local iface=""
local mac=""
for iface in br-lan eth0 eth1 wan lan wlan0 wlan1; do
mac="$(cat "/sys/class/net/${iface}/address" 2>/dev/null || true)"
mac="$(ss_identity_normalize_mac "$mac")"
if ss_identity_valid_mac "$mac"; then
printf '%s' "$mac"
return 0
fi
done
if ss_identity_command_exists ip; then
mac="$(ip link 2>/dev/null | awk '/link\/ether/ { print $2; exit }')"
mac="$(ss_identity_normalize_mac "$mac")"
if ss_identity_valid_mac "$mac"; then
printf '%s' "$mac"
return 0
fi
fi
return 1
}
ss_identity_compute_physical() {
local model="$1"
local arch="$2"
local board=""
local profile=""
local value=""
local source=""
local result=""
local material=""
board="$(ss_identity_board_name)"
profile="$(ss_identity_profile_code "$board")"
SS_IDENTITY_PROFILE="$profile"
# SmartSafeHub 전용 기기는 factory-programmed device_id를 우선 사용
case "$profile" in
smartsafehub*)
result="$(ss_identity_try_factory_device_id 2>/dev/null || true)"
if [ -n "$result" ]; then
value="${result%%|*}"
source="${result#*|}"
SS_IDENTITY_PROVIDER="factory_device_id"
SS_IDENTITY_SOURCE="$source"
SS_IDENTITY_STRENGTH="hardware_strong"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
material="safeshield-physical-v1|provider=${SS_IDENTITY_PROVIDER}|source=${SS_IDENTITY_SOURCE}|profile=${profile}|value_sha256=${SS_IDENTITY_VALUE_SHA256}"
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "$material")" || return 1
return 0
fi
;;
esac
# 기존 판매 공유기는 factory/ART/Factory 파티션 MAC을 우선 사용
result="$(ss_identity_try_mtd_factory_mac "$board" 2>/dev/null || true)"
if [ -n "$result" ]; then
value="${result%%|*}"
source="${result#*|}"
SS_IDENTITY_PROVIDER="factory_mac"
SS_IDENTITY_SOURCE="$source"
SS_IDENTITY_STRENGTH="hardware_soft"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
material="safeshield-physical-v1|provider=${SS_IDENTITY_PROVIDER}|source=${SS_IDENTITY_SOURCE}|profile=${profile}|mac_sha256=${SS_IDENTITY_VALUE_SHA256}"
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "$material")" || return 1
return 0
fi
# Last hardware-derived fallback. This is not fully reinstall-stable if the
# user changes MAC clone settings, but it is better than failing completely.
value="$(ss_identity_detect_primary_mac 2>/dev/null || true)"
if [ -n "$value" ]; then
SS_IDENTITY_PROVIDER="interface_mac"
SS_IDENTITY_SOURCE="sysfs:/sys/class/net/*/address"
SS_IDENTITY_STRENGTH="hardware_soft_unverified"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
material="safeshield-physical-v1|provider=${SS_IDENTITY_PROVIDER}|source=${SS_IDENTITY_SOURCE}|profile=${profile}|model=${model}|arch=${arch}|mac_sha256=${SS_IDENTITY_VALUE_SHA256}"
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "$material")" || return 1
return 0
fi
return 1
}
ss_identity_load() {
[ -r "$SS_IDENTITY_FILE" ] || return 1
# shellcheck disable=SC1090
. "$SS_IDENTITY_FILE"
SS_IDENTITY_VERSION="${IDENTITY_VERSION:-}"
SS_FINGERPRINT_VERSION="${FINGERPRINT_VERSION:-}"
SS_PHYSICAL_FINGERPRINT="${PHYSICAL_FINGERPRINT:-}"
SS_IDENTITY_PROVIDER="${IDENTITY_PROVIDER:-}"
SS_IDENTITY_SOURCE="${IDENTITY_SOURCE:-}"
SS_IDENTITY_STRENGTH="${IDENTITY_STRENGTH:-}"
SS_IDENTITY_PROFILE="${IDENTITY_PROFILE:-}"
SS_IDENTITY_VALUE_SHA256="${IDENTITY_VALUE_SHA256:-}"
SS_INSTALLATION_ID="${INSTALLATION_ID:-}"
SS_INSTALLATION_SECRET="${INSTALLATION_SECRET:-}"
SS_IDENTITY_CREATED_AT="${CREATED_AT:-}"
SS_IDENTITY_UPDATED_AT="${UPDATED_AT:-}"
ss_identity_valid_sha256 "$SS_PHYSICAL_FINGERPRINT" || return 1
[ -n "$SS_INSTALLATION_ID" ] || return 1
[ -n "$SS_IDENTITY_PROVIDER" ] || return 1
[ -n "$SS_IDENTITY_SOURCE" ] || return 1
[ -n "$SS_IDENTITY_STRENGTH" ] || return 1
[ -n "$SS_IDENTITY_PROFILE" ] || return 1
return 0
}
ss_identity_write_env() {
local tmp="${SS_IDENTITY_FILE}.tmp"
mkdir -p "$SS_IDENTITY_DIR" || return 1
chmod 700 "$SS_IDENTITY_DIR" 2>/dev/null || true
cat >"$tmp" <<EOF_ENV
IDENTITY_VERSION='${SS_IDENTITY_VERSION}'
FINGERPRINT_VERSION='${SS_FINGERPRINT_VERSION}'
PHYSICAL_FINGERPRINT='${SS_PHYSICAL_FINGERPRINT}'
IDENTITY_PROVIDER='${SS_IDENTITY_PROVIDER}'
IDENTITY_SOURCE='${SS_IDENTITY_SOURCE}'
IDENTITY_STRENGTH='${SS_IDENTITY_STRENGTH}'
IDENTITY_PROFILE='${SS_IDENTITY_PROFILE}'
IDENTITY_VALUE_SHA256='${SS_IDENTITY_VALUE_SHA256}'
INSTALLATION_ID='${SS_INSTALLATION_ID}'
INSTALLATION_SECRET='${SS_INSTALLATION_SECRET}'
CREATED_AT='${SS_IDENTITY_CREATED_AT}'
UPDATED_AT='${SS_IDENTITY_UPDATED_AT}'
EOF_ENV
chmod 600 "$tmp" 2>/dev/null || true
mv -f "$tmp" "$SS_IDENTITY_FILE"
}
ss_identity_sync_uci() {
uci -q batch <<EOF_UCI
set safeshield.identity=identity
set safeshield.identity.physical_fingerprint='${SS_PHYSICAL_FINGERPRINT}'
set safeshield.identity.fingerprint_version='${SS_FINGERPRINT_VERSION}'
set safeshield.identity.identity_provider='${SS_IDENTITY_PROVIDER}'
set safeshield.identity.identity_source='${SS_IDENTITY_SOURCE}'
set safeshield.identity.identity_strength='${SS_IDENTITY_STRENGTH}'
set safeshield.identity.identity_profile='${SS_IDENTITY_PROFILE}'
set safeshield.identity.device_code='${SS_DEVICE_CODE}'
set safeshield.identity.device_code_source='${SS_DEVICE_CODE_SOURCE}'
set safeshield.identity.installation_id='${SS_INSTALLATION_ID}'
set safeshield.identity.created_at='${SS_IDENTITY_CREATED_AT}'
set safeshield.identity.updated_at='${SS_IDENTITY_UPDATED_AT}'
commit safeshield
EOF_UCI
}
ss_identity_status_set() {
command -v ss_status_set >/dev/null 2>&1 || return 0
ss_status_set physical_fingerprint "$SS_PHYSICAL_FINGERPRINT"
ss_status_set fingerprint_version "$SS_FINGERPRINT_VERSION"
ss_status_set identity_provider "$SS_IDENTITY_PROVIDER"
ss_status_set identity_source "$SS_IDENTITY_SOURCE"
ss_status_set identity_strength "$SS_IDENTITY_STRENGTH"
ss_status_set identity_profile "$SS_IDENTITY_PROFILE"
ss_status_set device_code "$SS_DEVICE_CODE"
ss_status_set device_code_source "$SS_DEVICE_CODE_SOURCE"
ss_status_set installation_id "$SS_INSTALLATION_ID"
}
ss_identity_create() {
local model="$1"
local arch="$2"
local now=""
local value=""
now="$(date +%s)"
SS_FINGERPRINT_VERSION="1"
SS_INSTALLATION_ID="$(ss_identity_uuid)"
SS_INSTALLATION_SECRET="$(ss_identity_random_hex_32)"
SS_IDENTITY_CREATED_AT="$now"
SS_IDENTITY_UPDATED_AT="$now"
if ! ss_identity_compute_physical "$model" "$arch" || ! ss_identity_valid_sha256 "$SS_PHYSICAL_FINGERPRINT"; then
# Final fallback only when no physical source exists. This preserves
# basic operation but cannot survive full reinstall/factory reset.
value="$(ss_identity_random_hex_32)"
SS_IDENTITY_PROVIDER="installation_random"
SS_IDENTITY_SOURCE="${SS_IDENTITY_FILE}"
SS_IDENTITY_STRENGTH="fallback_random"
SS_IDENTITY_PROFILE="$(ss_identity_profile_code "$(ss_identity_board_name)")"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "safeshield-physical-v1|provider=installation_random|profile=${SS_IDENTITY_PROFILE}|value_sha256=${SS_IDENTITY_VALUE_SHA256}")" || return 1
ss_identity_log_warn "No stable hardware identity source found; using installation-random physical_fingerprint"
fi
ss_identity_refresh_device_code
ss_identity_write_env || return 1
ss_identity_sync_uci || true
ss_identity_status_set || true
ss_identity_log_info "SafeShield identity initialized provider=${SS_IDENTITY_PROVIDER} source=${SS_IDENTITY_SOURCE}"
}
ss_identity_ensure() {
local model="${1:-}"
local arch="${2:-}"
if ss_identity_load; then
ss_identity_refresh_device_code
ss_identity_sync_uci || true
ss_identity_status_set || true
return 0
fi
ss_identity_create "$model" "$arch"
}
+63
View File
@@ -0,0 +1,63 @@
# shellcheck shell=ash
readonly __DOT__='[.]'
readonly __OK__='\033[0;32m[\xe2\x9c\x93]\033[0m'
readonly _ERROR_='\033[0;31m[ERROR]\033[0m'
readonly _WARNING_='\033[0;33m[WARN]\033[0m'
log_line() {
[ -z "$ss_verbosity" ] && ss_verbosity="$(uci_get "$PKG_NAME" 'config' 'verbosity' '1')"
if [ "$#" -gt 1 ]; then
case "$1" in
[0-9])
[ $((ss_verbosity & $1)) -gt 0 ] || return 0
shift
;;
esac
fi
local msg="$*"
local queue="/dev/shm/${PKG_NAME}-output-$$"
if [ -z "$_out_is_tty" ]; then
if [ -t 1 ]; then
_out_is_tty=1
else
_out_is_tty=0
fi
fi
[ "$_out_is_tty" -eq 1 ] && printf '%b' "$msg"
case "$msg" in
*\\n*)
if [ -s "$queue" ]; then
msg="$(cat "$queue")$msg"
rm -f "$queue"
fi
msg="$(printf '%b' "$msg" | sed 's/\x1b\[[0-9;]*m//g')"
logger -t "$PKG_NAME [$$]" -- "$msg"
;;
*)
printf '%b' "$msg" >>"$queue"
;;
esac
}
log_info() {
log_line 1 "${__DOT__} $*\n"
}
log_ok() {
log_line 1 "${__OK__} $*\n"
}
log_warn() {
log_line 1 "${_WARNING_} $*\n"
}
log_error() {
log_line 1 "${_ERROR_} $*\n"
}
@@ -0,0 +1,398 @@
# shellcheck shell=ash
# Paths are defined by core.sh before these helpers are called.
# shellcheck disable=SC2154
SS_STATISTICS_POLL_COMMAND="${SS_STATISTICS_POLL_COMMAND:-/usr/libexec/safeshield-stats-poll}"
SS_STATISTICS_REBASELINE_FILE="${SS_STATISTICS_REBASELINE_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/rebaseline}"
ss_statistics_profile_code() {
local board=""
local profile=""
if command -v ss_identity_board_name >/dev/null 2>&1 && command -v ss_identity_profile_code >/dev/null 2>&1; then
board="$(ss_identity_board_name 2>/dev/null || true)"
if [ -n "$board" ]; then
profile="$(ss_identity_profile_code "$board" 2>/dev/null || true)"
fi
fi
case "$profile" in
'' | unknown)
[ -n "${SS_IDENTITY_PROFILE:-}" ] && profile="${SS_IDENTITY_PROFILE}"
;;
esac
[ -n "$profile" ] || profile="unknown"
printf '%s\n' "$profile"
}
ss_statistics_effective_snapshot_interval() {
local configured="${1:-60}"
if [ "$configured" = "60" ] && [ "$(ss_statistics_profile_code)" = "gl_mt300n_v2" ]; then
printf '%s\n' '300'
return 0
fi
printf '%s\n' "$configured"
}
ss_statistics_persistence_enabled() {
case "$(ss_statistics_profile_code)" in
gl_mt300n_v2)
return 1
;;
*)
return 0
;;
esac
}
ss_statistics_cleanup_legacy_dnsmasq_logging() {
if [ -f "${SS_STATISTICS_DNSMASQ_CONF}" ]; then
rm -f "${SS_STATISTICS_DNSMASQ_CONF}" || return 1
printf '%s\n' '1'
else
printf '%s\n' '0'
fi
}
# Kept as an internal compatibility wrapper for the init script. Statistics no
# longer enables dnsmasq query logging; this only removes a pre-0.3.20-r2 file.
ss_statistics_configure_dnsmasq() {
ss_statistics_cleanup_legacy_dnsmasq_logging
}
ss_statistics_source_available() {
[ -x "$SS_STATISTICS_POLL_COMMAND" ] || return 1
"$SS_STATISTICS_POLL_COMMAND" >/dev/null 2>&1
}
ss_statistics_request_rebaseline() {
mkdir -p "${SS_STATISTICS_REBASELINE_FILE%/*}" || return 1
: >"${SS_STATISTICS_REBASELINE_FILE}" || return 1
}
ss_statistics_disable_if_dnsmasq_unpatched() {
[ "${ss_enabled}" = "1" ] || return 0
[ "${ss_statistics_enabled}" = "1" ] || return 0
ss_dnsmasq_safeshield_block_supported && return 0
log_warn 'Disabling SafeShield statistics at runtime because dnsmasq does not provide the SafeShield extension'
ss_status_set health_dnsmasq_features '0'
ss_status_add_warning 'statistics_disabled_dnsmasq_safeshield_patch_unavailable'
# Keep the configured preference intact. A later firmware upgrade that adds
# the dnsmasq extension can therefore restore statistics automatically.
ss_statistics_enabled='0'
return 0
}
ss_statistics_add_procd_instance() {
procd_open_instance statistics
procd_set_param command /usr/libexec/safeshield-statsd
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param file /etc/config/safeshield
procd_set_param data service safeshield-statistics
procd_close_instance
}
ss_statistics_add_upload_procd_instance() {
procd_open_instance statistics-upload
procd_set_param command /usr/libexec/safeshield-statistics-uploader
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param file /etc/config/safeshield
procd_set_param data service safeshield-statistics-upload
procd_close_instance
}
ss_statistics_reconcile_runtime() {
local legacy_dnsmasq_changed=0
if [ "${ss_enabled}" = "1" ] && [ "${ss_statistics_enabled}" = "1" ]; then
ss_require_supported_dnsmasq || {
log_error "Failed dnsmasq compatibility check while enabling statistics"
return 1
}
ss_statistics_disable_if_dnsmasq_unpatched || return 1
fi
if [ "${ss_enabled}" != "1" ] || [ "${ss_statistics_enabled}" != "1" ]; then
procd_kill "${PKG_NAME}" statistics >/dev/null 2>&1 || true
procd_kill "${PKG_NAME}" statistics-upload >/dev/null 2>&1 || true
ss_statistics_request_rebaseline || {
log_error "Failed to mark statistics for rebaseline while statistics are inactive"
return 1
}
legacy_dnsmasq_changed="$(ss_statistics_cleanup_legacy_dnsmasq_logging)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
if [ "$legacy_dnsmasq_changed" = "1" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
return 0
fi
legacy_dnsmasq_changed="$(ss_statistics_cleanup_legacy_dnsmasq_logging)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
if [ "$legacy_dnsmasq_changed" = "1" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
ss_statistics_source_available || {
log_error "dnsmasq SafeShield statistics UBus source is unavailable"
return 1
}
procd_open_service "${PKG_NAME}"
ss_statistics_add_procd_instance
ss_statistics_add_upload_procd_instance
procd_close_service add
}
ss_statistics_upload_credentials_path() {
printf '%s\n' "${SS_STATISTICS_UPLOAD_CREDENTIALS_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.credentials}"
}
ss_statistics_upload_entitlement_path() {
printf '%s\n' "${SS_STATISTICS_UPLOAD_ENTITLEMENT_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.entitlement}"
}
ss_statistics_set_upload_entitlement() {
local state="$1"
local path tmp old_umask checked_at
case "$state" in
allowed | denied) ;;
unknown)
rm -f "$(ss_statistics_upload_entitlement_path)"
return 0
;;
*) return 1 ;;
esac
checked_at="$(date +%s 2>/dev/null || printf '0')"
is_valid_integer "$checked_at" || checked_at=0
path="$(ss_statistics_upload_entitlement_path)"
tmp="${path}.tmp.$$"
mkdir -p "${path%/*}" || return 1
old_umask="$(umask)"
umask 077
printf '%s\n%s\n' "$state" "$checked_at" >"$tmp" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
mv -f "$tmp" "$path" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
umask "$old_umask"
}
ss_statistics_upload_entitlement() {
local state
state="$(sed -n '1p' "$(ss_statistics_upload_entitlement_path)" 2>/dev/null)"
case "$state" in
allowed | denied) printf '%s\n' "$state" ;;
*) printf '%s\n' 'unknown' ;;
esac
}
ss_statistics_upload_denied() {
[ "$(ss_statistics_upload_entitlement)" = 'denied' ]
}
ss_statistics_upload_denied_recheck_due() {
local interval_s="${1:-43200}"
local checked_at now elapsed
ss_statistics_upload_denied || return 1
is_valid_integer "$interval_s" || interval_s=43200
[ "$interval_s" -ge 60 ] 2>/dev/null || interval_s=43200
checked_at="$(sed -n '2p' "$(ss_statistics_upload_entitlement_path)" 2>/dev/null)"
is_valid_integer "$checked_at" || return 0
[ "$checked_at" -gt 0 ] 2>/dev/null || return 0
now="$(date +%s 2>/dev/null || printf '0')"
is_valid_integer "$now" || return 0
[ "$now" -gt 0 ] 2>/dev/null || return 0
# A backward wall-clock correction must not postpone entitlement recovery.
[ "$now" -lt "$checked_at" ] 2>/dev/null && return 0
elapsed=$((now - checked_at))
[ "$elapsed" -ge "$interval_s" ]
}
ss_statistics_clear_upload_credentials() {
rm -f "$(ss_statistics_upload_credentials_path)"
SS_STATISTICS_UPLOAD_URL=''
SS_STATISTICS_UPLOAD_TOKEN=''
SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S='0'
}
ss_statistics_disable_cloud_upload() {
ss_statistics_clear_upload_credentials
rm -f \
"${SS_STATISTICS_UPLOAD_PENDING_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.pending.json}" \
"${SS_STATISTICS_UPLOAD_PENDING_META_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.pending.meta}" \
"${SS_STATISTICS_UPLOAD_STATE_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.state}"
ss_statistics_set_upload_entitlement denied
}
ss_statistics_store_upload_credentials() {
local response="$1"
local path tmp url token expires_in old_umask
[ -s "$response" ] || return 1
url="$(ss_json_get_file "$response" '@.statistics.upload_url')"
token="$(ss_json_get_file "$response" '@.statistics.token')"
expires_in="$(ss_json_get_file "$response" '@.statistics.token_expires_in_s')"
case "$url" in
https://www.smartsafehub.com/api/v1/statistics | https://www.smartsafehub.com/api/v1/statistics/) ;;
*)
return 1
;;
esac
[ -n "$token" ] || return 1
[ "${#token}" -le 4096 ] 2>/dev/null || return 1
if printf '%s' "$token" | grep -q '[[:space:]]'; then
return 1
fi
is_valid_integer "$expires_in" || expires_in='0'
path="$(ss_statistics_upload_credentials_path)"
tmp="${path}.tmp.$$"
mkdir -p "${path%/*}" || return 1
old_umask="$(umask)"
umask 077
printf '%s\n%s\n%s\n' "$url" "$token" "$expires_in" >"$tmp" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
chmod 600 "$tmp" 2>/dev/null || true
mv -f "$tmp" "$path" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
umask "$old_umask"
ss_statistics_set_upload_entitlement allowed || true
return 0
}
ss_statistics_sync_upload_entitlement() {
local response="$1"
local url token
[ -s "$response" ] || return 1
url="$(ss_json_get_file "$response" '@.statistics.upload_url')"
token="$(ss_json_get_file "$response" '@.statistics.token')"
if [ -z "$url" ] && [ -z "$token" ]; then
ss_statistics_disable_cloud_upload || return 1
return 2
fi
if [ -z "$url" ] || [ -z "$token" ]; then
ss_statistics_clear_upload_credentials
ss_statistics_set_upload_entitlement unknown || true
return 1
fi
ss_statistics_store_upload_credentials "$response"
}
ss_statistics_load_upload_credentials() {
local path
path="$(ss_statistics_upload_credentials_path)"
SS_STATISTICS_UPLOAD_URL=''
SS_STATISTICS_UPLOAD_TOKEN=''
SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S='0'
ss_statistics_upload_denied && {
ss_statistics_clear_upload_credentials
return 1
}
[ -s "$path" ] || return 1
SS_STATISTICS_UPLOAD_URL="$(sed -n '1p' "$path" 2>/dev/null)"
SS_STATISTICS_UPLOAD_TOKEN="$(sed -n '2p' "$path" 2>/dev/null)"
SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S="$(sed -n '3p' "$path" 2>/dev/null)"
case "$SS_STATISTICS_UPLOAD_URL" in
https://www.smartsafehub.com/api/v1/statistics | https://www.smartsafehub.com/api/v1/statistics/) ;;
*)
ss_statistics_clear_upload_credentials
return 1
;;
esac
[ -n "$SS_STATISTICS_UPLOAD_TOKEN" ] || {
ss_statistics_clear_upload_credentials
return 1
}
is_valid_integer "$SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S" || SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S='0'
return 0
}
ss_statistics_refresh_upload_credentials() {
local request response resolve_url retries rc=1
resolve_url='https://www.smartsafehub.com/api/v1/licenses/resolve'
request="${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload-resolve-request.json"
response="${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload-resolve-response.json"
ss_load_config || return 1
mkdir -p "${request%/*}" || return 1
ss_refresh_lock_open_wait "${ss_download_timeout:-10}" || return $?
if ! ss_write_resolve_payload "$request"; then
ss_refresh_lock_close
return 1
fi
retries=1
while [ "$retries" -le "${ss_download_retry:-3}" ]; do
ss_should_stop && {
rc=130
break
}
if ss_http_post_json "$resolve_url" "$request" "$response" && [ -s "$response" ]; then
rc=0
ss_statistics_sync_upload_entitlement "$response" || rc=$?
break
fi
if [ "${SS_HTTP_STATUS:-}" = '426' ]; then
log_error 'Hub requires a newer SafeShield version before statistics can be uploaded'
break
fi
retries=$((retries + 1))
[ "$retries" -le "${ss_download_retry:-3}" ] && sleep 1
done
rm -f "$request" "$response" "${response}.uclient.txt"
ss_refresh_lock_close
return "$rc"
}
@@ -0,0 +1,182 @@
# SafeShield statistics: common counter and device helpers.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
# SafeShield dnsmasq statistics collector.
# Production input is a low-frequency snapshot stream generated from dnsmasq
# cumulative UBus counters. Legacy log fixtures remain accepted for regression
# coverage. Raw queried domains are never persisted.
function numeric(value, fallback) {
if (value ~ /^[0-9]+$/) {
return value + 0
}
return fallback
}
function current_time() {
if (fixed_now > 0) {
return fixed_now + (event_index * fixed_step)
}
return systime()
}
function hour_start(epoch) {
return int(epoch / 3600) * 3600
}
function device_bucket_key(key, bucket) {
return key SUBSEP bucket
}
function prune_buckets(now, cutoff, bucket, composite, parts) {
cutoff = hour_start(now) - ((retention_hours - 1) * 3600)
for (bucket in queries) {
if ((bucket + 0) < cutoff) {
delete queries[bucket]
delete blocked[bucket]
}
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
bucket = parts[2] + 0
if (bucket < cutoff) {
delete device_hour_queries[composite]
delete device_hour_blocked[composite]
}
}
}
function normalize_state_field(value, normalized) {
normalized = value
gsub(/[\t\r\n]/, "_", normalized)
return (normalized == "") ? "*" : normalized
}
function decode_state_field(value) {
return (value == "*") ? "" : value
}
function normalize_mac(value, parts, count, i, mac) {
mac = tolower(value)
count = split(mac, parts, ":")
if (count != 6 || mac == "00:00:00:00:00:00") {
return ""
}
for (i = 1; i <= count; i++) {
if (parts[i] !~ /^[0-9a-f][0-9a-f]$/) {
return ""
}
}
return mac
}
function clear_device_identity_owner(key, client_id, duid) {
client_id = device_client_id[key]
duid = device_duid[key]
if (client_id != "" && client_id_owner[client_id] == key) {
delete client_id_owner[client_id]
}
if (duid != "" && duid_owner[duid] == key) {
delete duid_owner[duid]
}
}
function remove_device_metadata(key) {
if (!(key in device_seen)) {
return
}
clear_device_identity_owner(key)
delete device_seen[key]
delete device_mac[key]
delete device_ip[key]
delete device_hostname[key]
delete device_client_id[key]
delete device_duid[key]
delete device_queries[key]
delete device_blocked[key]
device_count--
}
function remove_device(key, composite, parts) {
if (!(key in device_seen)) {
return
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
if (parts[1] == key) {
delete device_hour_queries[composite]
delete device_hour_blocked[composite]
}
}
remove_device_metadata(key)
}
function register_device(key, mac, ip, hostname, client_id, duid, selected, owner) {
if (key == "") {
return ""
}
selected = key
if (!(selected in device_seen) && device_count >= max_devices && selected != "other" && selected != "unknown") {
devices_truncated = 1
selected = "other"
mac = ""
ip = ""
hostname = "Other devices"
client_id = ""
duid = ""
}
if (!(selected in device_seen)) {
# Initialize every per-device array element together. Besides keeping the
# device record structurally complete, this avoids passing a missing awk
# associative-array element to helper functions during serialization.
device_seen[selected] = 1
device_mac[selected] = ""
device_ip[selected] = ""
device_hostname[selected] = ""
device_client_id[selected] = ""
device_duid[selected] = ""
device_queries[selected] = 0
device_blocked[selected] = 0
device_count++
}
if (selected != "other" && selected != "unknown") {
if (mac != "") {
device_mac[selected] = tolower(mac)
}
if (ip != "") {
device_ip[selected] = ip
}
if (hostname != "" && hostname != "*") {
device_hostname[selected] = hostname
}
if (client_id != "") {
owner = client_id_owner[client_id]
if (owner == "" || owner == selected) {
device_client_id[selected] = client_id
client_id_owner[client_id] = selected
}
}
if (duid != "") {
owner = duid_owner[duid]
if (owner == "" || owner == selected) {
device_duid[selected] = duid
duid_owner[duid] = selected
}
}
}
else if (selected == "other") {
device_hostname[selected] = "Other devices"
}
else {
device_hostname[selected] = "Unknown devices"
}
return selected
}
@@ -0,0 +1,450 @@
# SafeShield statistics: state and journal recovery.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function state_file_updated_at(path, line, fields, count, meta_seen, meta_updated, expected_queries, expected_blocked, bucket_queries, bucket_blocked) {
if (path == "") {
return -1
}
meta_seen = 0
meta_updated = 0
expected_queries = 0
expected_blocked = 0
bucket_queries = 0
bucket_blocked = 0
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 5 && fields[1] == "meta") {
meta_seen = 1
meta_updated = numeric(fields[3], 0)
expected_queries = numeric(fields[4], 0)
expected_blocked = numeric(fields[5], 0)
}
else if (count >= 4 && fields[1] == "bucket") {
bucket_queries += numeric(fields[3], 0)
bucket_blocked += numeric(fields[4], 0)
}
}
close(path)
if (!meta_seen) {
return -1
}
if (bucket_queries != expected_queries || bucket_blocked != expected_blocked) {
return -1
}
return meta_updated
}
function state_file_generation_id(path, line, fields, count) {
if (path == "") {
return ""
}
if ((getline line < path) <= 0) {
close(path)
return ""
}
close(path)
count = split(line, fields, "\t")
if (count < 15 || fields[1] != "meta") {
return ""
}
return decode_state_field(fields[15])
}
function state_file_snapshot_seq(path, line, fields, count, sequence) {
if (path == "") {
return -1
}
sequence = -1
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 16 && fields[1] == "meta") {
sequence = numeric(fields[16], 0)
break
}
}
close(path)
return sequence
}
function journal_file_snapshot_seq(path, line, fields, count, current_txn, current_seq, latest_seq) {
if (path == "") {
return -1
}
current_txn = ""
current_seq = 0
latest_seq = -1
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 13 && fields[1] == "begin") {
current_txn = fields[2]
current_seq = numeric(fields[13], 0)
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
latest_seq = current_seq
current_txn = ""
current_seq = 0
}
}
close(path)
return latest_seq
}
function journal_file_generation_id(path, line, fields, count, current_txn, current_generation, latest_generation) {
if (path == "") {
return ""
}
current_txn = ""
current_generation = ""
latest_generation = ""
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
current_generation = (count >= 12) ? decode_state_field(fields[12]) : ""
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
latest_generation = current_generation
current_txn = ""
current_generation = ""
}
}
close(path)
return latest_generation
}
function load_sequence_reservation(path, expected_generation, line, fields, count, file_generation, reserved) {
if (path == "" || expected_generation == "") {
return 0
}
if ((getline line < path) <= 0) {
close(path)
return 0
}
close(path)
count = split(line, fields, "\t")
if (count < 3 || fields[1] != "reserve") {
return 0
}
file_generation = decode_state_field(fields[2])
if (file_generation != expected_generation) {
return 0
}
reserved = numeric(fields[3], 0)
return (reserved > 0) ? reserved : 0
}
function load_state_file(path, line, fields, count, bucket, key, mac, ip, hostname, client_id, duid, composite) {
if (path == "") {
return 0
}
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 5 && fields[1] == "meta") {
started_at = numeric(fields[2], 0)
updated_at = numeric(fields[3], 0)
total_queries = numeric(fields[4], 0)
total_blocked = numeric(fields[5], 0)
if (count >= 6) {
devices_truncated = numeric(fields[6], 0) ? 1 : 0
}
if (count >= 7) {
persistent_updated_at = numeric(fields[7], 0)
}
if (count >= 8) {
state_schema_version = numeric(fields[8], 1)
}
if (count >= 9) {
loaded_session_started_at = numeric(fields[9], 0)
}
if (count >= 10) {
persistence_healthy = numeric(fields[10], 1) ? 1 : 0
}
if (count >= 11) {
persistent_error_count = numeric(fields[11], 0)
}
if (count >= 12) {
persistent_last_error_at = numeric(fields[12], 0)
}
if (count >= 13) {
persistent_compacted_at = numeric(fields[13], 0)
}
if (count >= 14) {
last_journal_completed_bucket = numeric(fields[14], 0)
}
if (count >= 15) {
generation_id = decode_state_field(fields[15])
}
if (count >= 16) {
snapshot_seq = numeric(fields[16], 0)
}
if (count >= 17) {
snapshot_seq_reserved_through = numeric(fields[17], 0)
}
loaded_state = 1
}
else if (count >= 8 && fields[1] == "source") {
source_instance_id = decode_state_field(fields[2])
source_transport_scope = decode_state_field(fields[3])
source_client_capacity = numeric(fields[4], 0)
source_tracked_clients = numeric(fields[5], 0)
source_untracked_queries = numeric(fields[6], 0)
source_total_queries = numeric(fields[7], 0)
source_total_blocked = numeric(fields[8], 0)
source_available = (count >= 9 && numeric(fields[9], 0)) ? 1 : 0
source_error_count = (count >= 10) ? numeric(fields[10], 0) : 0
source_last_error_at = (count >= 11) ? numeric(fields[11], 0) : 0
source_untracked_blocked = (count >= 12) ? numeric(fields[12], 0) : 0
source_initialized = (source_instance_id != "") ? 1 : 0
}
else if (count >= 4 && fields[1] == "source_client") {
key = decode_state_field(fields[2])
if (key != "") {
source_client_queries[key] = numeric(fields[3], 0)
source_client_blocked[key] = numeric(fields[4], 0)
}
}
else if (count >= 4 && fields[1] == "bucket") {
bucket = numeric(fields[2], 0)
if (bucket > 0) {
queries[bucket] = numeric(fields[3], 0)
blocked[bucket] = numeric(fields[4], 0)
}
}
else if (count >= 7 && fields[1] == "device") {
key = decode_state_field(fields[2])
mac = decode_state_field(fields[3])
ip = decode_state_field(fields[4])
hostname = decode_state_field(fields[5])
client_id = (count >= 8) ? decode_state_field(fields[8]) : ""
duid = (count >= 9) ? decode_state_field(fields[9]) : ""
key = register_device(key, mac, ip, hostname, client_id, duid)
if (key != "") {
legacy_device_queries[key] += numeric(fields[6], 0)
legacy_device_blocked[key] += numeric(fields[7], 0)
}
}
else if (count >= 5 && fields[1] == "device_bucket") {
key = decode_state_field(fields[2])
bucket = numeric(fields[3], 0)
if (key != "" && bucket > 0) {
key = register_device(key, "", "", "")
if (key != "") {
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] += numeric(fields[4], 0)
device_hour_blocked[composite] += numeric(fields[5], 0)
device_has_hourly[key] = 1
}
}
}
}
close(path)
return loaded_state
}
function journal_file_updated_at(path, line, fields, count, line_no, current_txn, current_updated, last_commit_line, latest_updated) {
if (path == "") {
return -1
}
line_no = 0
current_txn = ""
current_updated = 0
last_commit_line = 0
latest_updated = -1
while ((getline line < path) > 0) {
line_no++
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
current_updated = numeric(fields[4], 0)
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
last_commit_line = line_no
latest_updated = current_updated
current_txn = ""
current_updated = 0
}
}
close(path)
return (last_commit_line > 0) ? latest_updated : -1
}
function load_journal_file(path, min_updated_at, min_snapshot_seq, line, fields, count, line_no, current_txn, current_start, active_end, txn_updated, txn_started, txn_truncated, txn_session_started, txn_completed, txn_healthy, txn_error_count, txn_last_error, txn_generation_id, txn_snapshot_seq, txn_snapshot_reserved, key, mac, ip, hostname, client_id, duid, bucket, composite, committed_end) {
if (path == "") {
return 0
}
# First pass records only complete transaction ranges. A power loss can leave
# a partial append at the tail (or before a later retry); those records must
# never be replayed unless their matching commit marker is present.
line_no = 0
current_txn = ""
current_start = 0
while ((getline line < path) > 0) {
line_no++
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
current_start = line_no
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
committed_end[current_start] = line_no
current_txn = ""
current_start = 0
}
}
close(path)
line_no = 0
current_txn = ""
active_end = 0
while ((getline line < path) > 0) {
line_no++
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
active_end = committed_end[line_no] + 0
if (active_end <= 0) {
current_txn = ""
continue
}
txn_updated = numeric(fields[4], 0)
txn_started = numeric(fields[5], 0)
txn_truncated = numeric(fields[6], 0) ? 1 : 0
txn_session_started = numeric(fields[7], 0)
txn_completed = numeric(fields[8], 0)
txn_healthy = numeric(fields[9], 1) ? 1 : 0
txn_error_count = numeric(fields[10], 0)
txn_last_error = numeric(fields[11], 0)
txn_generation_id = (count >= 12) ? decode_state_field(fields[12]) : ""
txn_snapshot_seq = (count >= 13) ? numeric(fields[13], 0) : 0
txn_snapshot_reserved = (count >= 14) ? numeric(fields[14], 0) : 0
# Schema-v3 journal transactions carry generation-scoped snapshot_seq.
# Prefer it over wall-clock timestamps so a backward NTP correction does
# not make a newer committed transaction look older than the base state.
if (min_snapshot_seq >= 0 && txn_snapshot_seq > 0) {
if (txn_snapshot_seq <= min_snapshot_seq) {
current_txn = ""
active_end = 0
continue
}
}
else if (min_updated_at >= 0 && txn_updated <= min_updated_at) {
current_txn = ""
active_end = 0
continue
}
continue
}
if (current_txn == "" || line_no > active_end) {
continue
}
if (count >= 4 && fields[1] == "bucket") {
bucket = numeric(fields[2], 0)
if (bucket > 0) {
queries[bucket] = numeric(fields[3], 0)
blocked[bucket] = numeric(fields[4], 0)
}
continue
}
if (count >= 5 && fields[1] == "device") {
key = decode_state_field(fields[2])
mac = decode_state_field(fields[3])
ip = decode_state_field(fields[4])
hostname = decode_state_field(fields[5])
client_id = (count >= 6) ? decode_state_field(fields[6]) : ""
duid = (count >= 7) ? decode_state_field(fields[7]) : ""
register_device(key, mac, ip, hostname, client_id, duid)
continue
}
if (count >= 5 && fields[1] == "device_bucket") {
key = decode_state_field(fields[2])
bucket = numeric(fields[3], 0)
if (key != "" && bucket > 0) {
key = register_device(key, "", "", "")
if (key != "") {
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] = numeric(fields[4], 0)
device_hour_blocked[composite] = numeric(fields[5], 0)
device_has_hourly[key] = 1
}
}
continue
}
if (count >= 2 && fields[1] == "delete_device") {
key = decode_state_field(fields[2])
if (key != "") {
journal_replaying = 1
remove_device(key)
journal_replaying = 0
}
continue
}
if (count >= 2 && fields[1] == "commit" && fields[2] == current_txn && line_no == active_end) {
if (txn_started > 0) {
started_at = txn_started
}
if (txn_generation_id != "") {
generation_id = txn_generation_id
}
if (txn_snapshot_seq > snapshot_seq) {
snapshot_seq = txn_snapshot_seq
}
if (txn_snapshot_reserved > snapshot_seq_reserved_through) {
snapshot_seq_reserved_through = txn_snapshot_reserved
}
devices_truncated = txn_truncated
if (txn_session_started > 0) {
loaded_session_started_at = txn_session_started
}
if (txn_completed > last_journal_completed_bucket) {
last_journal_completed_bucket = txn_completed
}
persistent_updated_at = txn_updated
persistence_healthy = txn_healthy
persistent_error_count = txn_error_count
persistent_last_error_at = txn_last_error
updated_at = txn_updated
loaded_state = 1
state_schema_version = 6
current_txn = ""
active_end = 0
}
}
close(path)
journal_replaying = 0
return loaded_state
}
function migrate_legacy_device_totals(now, key, bucket, composite) {
if (state_schema_version >= 2) {
return
}
bucket = hour_start((updated_at > 0) ? updated_at : now)
for (key in device_seen) {
if (device_has_hourly[key]) {
continue
}
if ((legacy_device_queries[key] + 0) == 0 && (legacy_device_blocked[key] + 0) == 0) {
continue
}
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] += legacy_device_queries[key] + 0
device_hour_blocked[composite] += legacy_device_blocked[key] + 0
device_has_hourly[key] = 1
}
}
@@ -0,0 +1,545 @@
# SafeShield statistics: IPv4/IPv6 client identity resolution.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function normalize_client_id(value, mac, normalized, flat, mac_flat) {
normalized = tolower(value)
if (normalized == "" || normalized == "*") {
return ""
}
# A DHCP client-id of type 1 merely embeds the current Ethernet MAC and does
# not add any stability when Android/iOS private MAC rotation is in use.
flat = normalized
gsub(/[:.-]/, "", flat)
mac_flat = tolower(mac)
gsub(/:/, "", mac_flat)
if (mac_flat != "" && flat == ("01" mac_flat)) {
return ""
}
return normalized
}
function normalize_duid(value, normalized) {
normalized = tolower(value)
gsub(/[:.-]/, "", normalized)
if (normalized == "" || normalized !~ /^[0-9a-f]+$/) {
return ""
}
return normalized
}
function hostname_identity_key(value, normalized) {
normalized = tolower(value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", normalized)
return normalized
}
function hostname_is_mergeable(value, normalized) {
normalized = hostname_identity_key(value)
if (normalized == "" || normalized == "*" || normalized == "unknown") {
return 0
}
# Generic factory/default hostnames are hints at best and can easily belong
# to multiple devices on the same LAN. Never use them for automatic merges.
if (normalized == "iphone" || normalized == "ipad" || normalized == "android" || \
normalized == "galaxy" || normalized == "phone" || normalized == "tablet" || \
normalized == "laptop" || normalized == "desktop" || normalized == "pc" || \
normalized == "openwrt" || normalized == "router" || normalized == "localhost") {
return 0
}
return 1
}
function add_ipv6_neighbor(line, fields, count, i, ip, mac) {
count = split(line, fields, /[[:space:]]+/)
if (count < 5) {
return
}
ip = fields[1]
mac = ""
for (i = 2; i < count; i++) {
if (fields[i] == "lladdr") {
mac = normalize_mac(fields[i + 1])
break
}
}
if (ip == "" || mac == "") {
return
}
# Keep DHCP metadata authoritative when it already knows this exact address.
# The NDP table fills only the missing link-layer mapping.
if (!(ip in lease_mac)) {
lease_mac[ip] = mac
}
if (!(ip in lease_hostname)) {
lease_hostname[ip] = ""
}
}
function refresh_ipv6_neighbors( line) {
if (ipv6_neigh_file != "") {
while ((getline line < ipv6_neigh_file) > 0) {
add_ipv6_neighbor(line)
}
close(ipv6_neigh_file)
return
}
if (ipv6_neigh_command == "") {
return
}
# `ip -6 neigh show` resolves IPv6 privacy/temporary addresses back to a
# link-layer address without storing the queried IPv6 address as identity.
while ((ipv6_neigh_command | getline line) > 0) {
add_ipv6_neighbor(line)
}
close(ipv6_neigh_command)
}
function add_ipv6_identity(line, fields, count, ip, duid, hostname) {
count = split(line, fields, /[\t]+/)
if (count < 2) {
return
}
ip = fields[1]
duid = normalize_duid(fields[2])
hostname = (count >= 3 && fields[3] != "*") ? fields[3] : ""
if (ip == "" || duid == "") {
return
}
lease_duid[ip] = duid
if (!(ip in lease_hostname) || lease_hostname[ip] == "") {
lease_hostname[ip] = hostname
}
}
function refresh_ipv6_identities( line) {
if (ipv6_identity_file != "") {
while ((getline line < ipv6_identity_file) > 0) {
add_ipv6_identity(line)
}
close(ipv6_identity_file)
return
}
if (ipv6_identity_command == "") {
return
}
while ((ipv6_identity_command | getline line) > 0) {
add_ipv6_identity(line)
}
close(ipv6_identity_command)
}
function clear_identity_cache( ip) {
for (ip in identity_key_cache) {
delete identity_key_cache[ip]
delete identity_cache_expires[ip]
}
}
function cache_identity_key(ip, key, now) {
if (ip == "" || key == "") {
return key
}
identity_key_cache[ip] = key
identity_cache_expires[ip] = now + identity_cache_ttl
return key
}
function clear_lease_identity( ip, key) {
for (ip in lease_mac) {
delete lease_mac[ip]
}
for (ip in lease_hostname) {
delete lease_hostname[ip]
}
for (ip in lease_client_id) {
delete lease_client_id[ip]
}
for (ip in lease_duid) {
delete lease_duid[ip]
}
for (key in active_mac) {
delete active_mac[key]
delete active_mac_ip[key]
delete active_mac_hostname[key]
delete active_mac_client_id[key]
delete active_mac_duid[key]
}
for (key in active_hostname_count) {
delete active_hostname_count[key]
delete active_hostname_mac[key]
}
for (key in active_hostname_pair) {
delete active_hostname_pair[key]
}
}
function rebuild_active_identity_index( ip, mac, hostname, hostname_key, pair, client_id, duid) {
for (ip in lease_mac) {
mac = lease_mac[ip]
if (mac == "") {
continue
}
active_mac[mac] = 1
if (active_mac_ip[mac] == "" || ip !~ /:/) {
active_mac_ip[mac] = ip
}
hostname = lease_hostname[ip]
if (hostname != "" && hostname != "*") {
active_mac_hostname[mac] = hostname
hostname_key = hostname_identity_key(hostname)
pair = hostname_key SUBSEP mac
if (hostname_key != "" && !(pair in active_hostname_pair)) {
active_hostname_pair[pair] = 1
active_hostname_count[hostname_key]++
active_hostname_mac[hostname_key] = mac
}
}
client_id = lease_client_id[ip]
duid = lease_duid[ip]
if (client_id != "") {
active_mac_client_id[mac] = client_id
}
if (duid != "") {
active_mac_duid[mac] = duid
}
}
}
function move_device_hours(from_key, to_key, composite, parts, bucket, target, count, i, move_keys) {
if (from_key == to_key) {
return
}
# Do not mutate the associative array while iterating over it. Some awk
# implementations may skip entries when keys are added/deleted during the
# same for-in traversal, leaving orphan identity buckets behind.
count = 0
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
if (parts[1] == from_key) {
move_keys[++count] = composite
}
}
for (i = 1; i <= count; i++) {
composite = move_keys[i]
split(composite, parts, SUBSEP)
bucket = parts[2] + 0
target = device_bucket_key(to_key, bucket)
device_hour_queries[target] += device_hour_queries[composite] + 0
device_hour_blocked[target] += device_hour_blocked[composite] + 0
delete device_hour_queries[composite]
delete device_hour_blocked[composite]
delete move_keys[i]
}
delete device_has_hourly[from_key]
if (count > 0) {
device_has_hourly[to_key] = 1
}
}
function migrate_device(from_key, to_key, mac, ip, hostname, client_id, duid, old_client_id, old_duid, selected) {
if (to_key == "") {
return ""
}
if (from_key == "" || !(from_key in device_seen) || from_key == to_key) {
return register_device(to_key, mac, ip, hostname, client_id, duid)
}
old_client_id = device_client_id[from_key]
old_duid = device_duid[from_key]
if (client_id == "") {
client_id = old_client_id
}
if (duid == "") {
duid = old_duid
}
if (hostname == "") {
hostname = device_hostname[from_key]
}
if (ip == "") {
ip = device_ip[from_key]
}
# A migration replaces the old logical identity atomically in the retained
# statistics dataset. Journal the tombstone and a full replacement device so
# a reboot cannot resurrect the old MAC/IP identity.
if (!journal_replaying) {
journal_deleted_device[from_key] = 1
}
remove_device_metadata(from_key)
selected = register_device(to_key, mac, ip, hostname, client_id, duid)
if (selected == "") {
return ""
}
move_device_hours(from_key, selected)
if (!journal_replaying) {
journal_full_device[selected] = 1
}
return selected
}
function resolve_device_identity(ip, mac, hostname, client_id, duid, selected, owner, ip_key) {
if (mac == "") {
return register_device("ip:" ip, "", ip, hostname, client_id, duid)
}
selected = mac
client_id = normalize_client_id(client_id, mac)
duid = normalize_duid(duid)
# Strong identifiers survive private/randomized MAC changes. Move the full
# retained history to the currently observed MAC so existing Hub schema-v3
# consumers keep the id == mac contract while SafeShield emits one device.
if (duid != "") {
owner = duid_owner[duid]
if (owner != "" && owner != selected) {
selected = migrate_device(owner, selected, mac, ip, hostname, client_id, duid)
}
}
if (client_id != "") {
owner = client_id_owner[client_id]
if (owner != "" && owner != selected) {
selected = migrate_device(owner, selected, mac, ip, hostname, client_id, duid)
}
}
ip_key = "ip:" ip
if (ip_key in device_seen && ip_key != selected) {
selected = migrate_device(ip_key, selected, mac, ip, hostname, client_id, duid)
}
return register_device(selected, mac, ip, hostname, client_id, duid)
}
function devices_have_conflicting_strong_identity(left, right) {
if (device_client_id[left] != "" && device_client_id[right] != "" && \
device_client_id[left] != device_client_id[right]) {
return 1
}
if (device_duid[left] != "" && device_duid[right] != "" && device_duid[left] != device_duid[right]) {
return 1
}
return 0
}
function reconcile_hostname_devices( hostname_key, target_mac, target_key, target_ip, target_hostname, target_client_id, target_duid, key, count, i, candidates) {
for (hostname_key in active_hostname_count) {
if ((active_hostname_count[hostname_key] + 0) != 1 || !hostname_is_mergeable(hostname_key)) {
continue
}
target_mac = active_hostname_mac[hostname_key]
if (target_mac == "") {
continue
}
target_key = target_mac
target_ip = active_mac_ip[target_mac]
target_hostname = active_mac_hostname[target_mac]
target_client_id = active_mac_client_id[target_mac]
target_duid = active_mac_duid[target_mac]
target_key = register_device(target_key, target_mac, target_ip, target_hostname, target_client_id, target_duid)
if (target_key == "") {
continue
}
count = 0
for (key in device_seen) {
if (key == target_key || device_mac[key] == "" || device_mac[key] in active_mac) {
continue
}
if (hostname_identity_key(device_hostname[key]) != hostname_key) {
continue
}
if (devices_have_conflicting_strong_identity(key, target_key)) {
continue
}
candidates[++count] = key
}
for (i = 1; i <= count; i++) {
key = candidates[i]
target_key = migrate_device(key, target_key, target_mac, target_ip, target_hostname, target_client_id, target_duid)
delete candidates[i]
}
}
}
function reconcile_ip_devices( ip, ip_key, mac, hostname, client_id, duid, owner) {
for (ip in lease_mac) {
mac = lease_mac[ip]
if (mac == "") {
continue
}
hostname = lease_hostname[ip]
client_id = lease_client_id[ip]
duid = lease_duid[ip]
ip_key = "ip:" ip
# Avoid materializing every DHCP/NDP lease as a zero-counter device.
# Reconcile eagerly only when retained history or a strong identity owner
# already exists; normal traffic registers a new active device on demand.
owner = ""
if (duid != "") {
owner = duid_owner[duid]
}
if (owner == "" && client_id != "") {
owner = client_id_owner[client_id]
}
if (!(ip_key in device_seen) && !(mac in device_seen) && owner == "") {
continue
}
resolve_device_identity(ip, mac, hostname, client_id, duid)
}
}
function refresh_leases(now, force, line, fields, count, mac, ip, hostname, client_id) {
if (!force && last_lease_refresh > 0 && (now - last_lease_refresh) < lease_refresh_interval) {
return
}
clear_identity_cache()
clear_lease_identity()
while ((getline line < lease_file) > 0) {
count = split(line, fields, /[[:space:]]+/)
if (count < 4) {
continue
}
mac = normalize_mac(fields[2])
ip = fields[3]
hostname = fields[4]
client_id = (count >= 5) ? normalize_client_id(fields[5], mac) : ""
if (mac == "" || ip == "") {
continue
}
lease_mac[ip] = mac
lease_hostname[ip] = (hostname == "*") ? "" : hostname
lease_client_id[ip] = client_id
}
close(lease_file)
# DHCP leases are not guaranteed to contain every active client. Static-IP
# clients and lease-file update windows can otherwise recreate an ip:*
# identity after the device has already been identified by MAC. Use the
# kernel ARP table as the current IPv4 identity fallback.
while ((getline line < arp_file) > 0) {
count = split(line, fields, /[[:space:]]+/)
if (count < 4) {
continue
}
ip = fields[1]
mac = normalize_mac(fields[4])
if (ip == "" || mac == "") {
continue
}
if (!(ip in lease_mac)) {
lease_mac[ip] = mac
lease_hostname[ip] = ""
lease_client_id[ip] = ""
}
}
close(arp_file)
# odhcpd exposes the DHCPv6 DUID while the kernel NDP cache exposes the MAC.
# Combining the two lets privacy IPv6 addresses and private MAC rotations
# converge without treating each temporary IPv6 address as a new device.
refresh_ipv6_identities()
refresh_ipv6_neighbors()
rebuild_active_identity_index()
reconcile_ip_devices()
reconcile_hostname_devices()
last_lease_refresh = now
}
function compact_stale_provisional_devices(now, cutoff, key, last_bucket, count, i, stale) {
cutoff = hour_start(now) - (provisional_retention_hours * 3600)
count = 0
for (key in device_seen) {
if (key !~ /^ip:/ || device_client_id[key] != "" || device_duid[key] != "") {
continue
}
last_bucket = device_last_bucket[key] + 0
if (last_bucket > 0 && last_bucket <= cutoff) {
stale[++count] = key
}
}
for (i = 1; i <= count; i++) {
key = stale[i]
migrate_device(key, "unknown", "", "", "Unknown devices", "", "")
delete stale[i]
}
return count
}
function is_internal_statistics_client(ip) {
return (ip == "::1" || ip ~ /^127\./)
}
function device_key_for_ip(ip, now, cached_key, mac, hostname, client_id, duid, selected) {
if (ip == "" || is_internal_statistics_client(ip)) {
return ""
}
if ((ip in identity_key_cache) && (identity_cache_expires[ip] + 0) > now) {
cached_key = identity_key_cache[ip]
if (cached_key != "") {
return cached_key
}
}
delete identity_key_cache[ip]
delete identity_cache_expires[ip]
refresh_leases(now, 0)
mac = lease_mac[ip]
hostname = lease_hostname[ip]
client_id = lease_client_id[ip]
duid = lease_duid[ip]
if (mac != "") {
selected = resolve_device_identity(ip, mac, hostname, client_id, duid)
}
else {
selected = register_device("ip:" ip, "", ip, hostname, client_id, duid)
}
return cache_identity_key(ip, selected, now)
}
function record_device(ip, query_delta, blocked_delta, now, key, bucket, composite) {
key = device_key_for_ip(ip, now)
if (key == "") {
return
}
bucket = hour_start(now)
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] += 0
device_hour_blocked[composite] += 0
if (query_delta > 0) {
device_hour_queries[composite] += query_delta
}
if (blocked_delta > 0) {
device_hour_blocked[composite] += blocked_delta
}
device_has_hourly[key] = 1
}
@@ -0,0 +1,86 @@
# SafeShield statistics: aggregation, log parsing, and serialization helpers.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function recompute_totals( bucket, composite, parts, key, stale_count, i) {
total_queries = 0
total_blocked = 0
for (bucket in queries) {
total_queries += queries[bucket] + 0
total_blocked += blocked[bucket] + 0
}
for (key in device_seen) {
device_queries[key] = 0
device_blocked[key] = 0
delete device_first_bucket[key]
delete device_last_bucket[key]
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
key = parts[1]
bucket = parts[2] + 0
device_queries[key] += device_hour_queries[composite] + 0
device_blocked[key] += device_hour_blocked[composite] + 0
if (!(key in device_first_bucket) || bucket < (device_first_bucket[key] + 0)) {
device_first_bucket[key] = bucket
}
if (!(key in device_last_bucket) || bucket > (device_last_bucket[key] + 0)) {
device_last_bucket[key] = bucket
}
}
stale_count = 0
for (key in device_seen) {
if ((device_queries[key] + 0) == 0 && (device_blocked[key] + 0) == 0) {
stale_devices[++stale_count] = key
}
}
for (i = 1; i <= stale_count; i++) {
remove_device(stale_devices[i])
delete stale_devices[i]
}
}
function extract_query_client(line, client) {
client = line
if (client !~ / from /) {
return ""
}
sub(/^.* from /, "", client)
sub(/[[:space:]].*$/, "", client)
return client
}
function extract_extra_client(line, payload, fields, count, client) {
payload = line
sub(/^.*dnsmasq\[[0-9]+\]:[[:space:]]*/, "", payload)
count = split(payload, fields, /[[:space:]]+/)
if (count < 3 || fields[1] !~ /^[0-9]+$/) {
return ""
}
client = fields[2]
sub(/\/[0-9]+$/, "", client)
return client
}
function shell_quote(value, quoted) {
quoted = value
gsub(/'/, "'\\''", quoted)
return "'" quoted "'"
}
function json_escape(value, escaped) {
escaped = value
gsub(/\\/, "\\\\", escaped)
gsub(/"/, "\\\"", escaped)
gsub(/\r/, "\\r", escaped)
gsub(/\n/, "\\n", escaped)
gsub(/\t/, "\\t", escaped)
return escaped
}
function replace_file(tmp, final, rc) {
rc = system("mv -f " shell_quote(tmp) " " shell_quote(final))
return rc == 0
}
@@ -0,0 +1,349 @@
# SafeShield statistics: tmpfs state and persistent journal writes.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function save_state_file(path, now, tmp, bucket, cutoff, key, composite, parts) {
if (path == "") {
return 0
}
tmp = path ".tmp"
printf "meta\t%d\t%d\t%d\t%d\t%d\t%d\t6\t%d\t%d\t%d\t%d\t%d\t%d\t%s\t%d\t%d\n", \
started_at, updated_at, total_queries, total_blocked, devices_truncated, \
persistent_updated_at, session_started_at, persistence_healthy, \
persistent_error_count, persistent_last_error_at, persistent_compacted_at, \
last_journal_completed_bucket, normalize_state_field(generation_id), \
snapshot_seq, snapshot_seq_reserved_through > tmp
if (source_initialized || source_error_count > 0) {
printf "source\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", \
normalize_state_field(source_instance_id), \
normalize_state_field(source_transport_scope), \
source_client_capacity, source_tracked_clients, source_untracked_queries, \
source_total_queries, source_total_blocked, source_available, \
source_error_count, source_last_error_at, source_untracked_blocked >> tmp
}
if (source_initialized) {
for (key in source_client_queries) {
printf "source_client\t%s\t%d\t%d\n", \
normalize_state_field(key), \
source_client_queries[key] + 0, \
source_client_blocked[key] + 0 >> tmp
}
}
cutoff = hour_start(now) - ((retention_hours - 1) * 3600)
for (bucket = cutoff; bucket <= hour_start(now); bucket += 3600) {
if ((bucket in queries) || (bucket in blocked)) {
printf "bucket\t%d\t%d\t%d\n", bucket, queries[bucket] + 0, blocked[bucket] + 0 >> tmp
}
}
for (key in device_seen) {
printf "device\t%s\t%s\t%s\t%s\t%d\t%d\t%s\t%s\n", \
normalize_state_field(key), \
normalize_state_field(device_mac[key]), \
normalize_state_field(device_ip[key]), \
normalize_state_field(device_hostname[key]), \
device_queries[key] + 0, \
device_blocked[key] + 0, \
normalize_state_field(device_client_id[key]), \
normalize_state_field(device_duid[key]) >> tmp
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
if ((parts[2] + 0) < cutoff) {
continue
}
printf "device_bucket\t%s\t%d\t%d\t%d\n", \
normalize_state_field(parts[1]), \
parts[2] + 0, \
device_hour_queries[composite] + 0, \
device_hour_blocked[composite] + 0 >> tmp
}
close(tmp)
if (!replace_file(tmp, path)) {
system("rm -f " shell_quote(tmp))
return 0
}
return 1
}
function save_sequence_reservation(path, generation, reserved_through, tmp) {
if (path == "" || generation == "" || reserved_through <= 0) {
return 0
}
tmp = path ".tmp"
printf "reserve\t%s\t%d\n", normalize_state_field(generation), reserved_through > tmp
close(tmp)
if (!replace_file(tmp, path)) {
system("rm -f " shell_quote(tmp))
return 0
}
return 1
}
function ensure_snapshot_sequence_reservation(now, next_seq, reserved_through) {
next_seq = snapshot_seq + 1
if (sequence_file == "") {
snapshot_seq = next_seq
if (snapshot_seq > snapshot_seq_reserved_through) {
snapshot_seq_reserved_through = snapshot_seq
}
return 1
}
if (next_seq > snapshot_seq_reserved_through) {
reserved_through = next_seq + snapshot_seq_reservation_size - 1
if (!save_sequence_reservation(sequence_file, generation_id, reserved_through)) {
persistence_healthy = 0
persistent_error_count++
persistent_last_error_at = now
return 0
}
snapshot_seq_reserved_through = reserved_through
}
snapshot_seq = next_seq
return 1
}
function append_file(source, target, rc) {
rc = system("cat " shell_quote(source) " >> " shell_quote(target))
return rc == 0
}
function clear_journal_file(path, rc) {
if (path == "") {
return 1
}
rc = system(": > " shell_quote(path))
return rc == 0
}
function schedule_next_persistent(now) {
next_persistent_at = hour_start(now) + persistent_interval
while (next_persistent_at <= now) {
next_persistent_at += persistent_interval
}
}
function schedule_next_compaction(now) {
if (persistent_compact_interval <= 0) {
next_compact_at = 0
return
}
if (persistent_compacted_at <= 0) {
persistent_compacted_at = now
}
next_compact_at = persistent_compacted_at + persistent_compact_interval
while (next_compact_at <= now) {
next_compact_at += persistent_compact_interval
}
}
function journal_has_pending_deletes( key) {
for (key in journal_deleted_device) {
return 1
}
return 0
}
function journal_has_pending_full_devices( key) {
for (key in journal_full_device) {
return 1
}
return 0
}
function clear_journal_pending( key) {
for (key in journal_deleted_device) {
delete journal_deleted_device[key]
}
for (key in journal_full_device) {
delete journal_full_device[key]
}
}
function write_journal_transaction(now, first_bucket, last_bucket, completed_through, tmp, txn_id, bucket, key, composite, wrote_device, device_first, device_last, cutoff) {
if (persistent_journal_file == "") {
return 0
}
if (first_bucket > last_bucket && !journal_has_pending_deletes() && !journal_has_pending_full_devices()) {
return 1
}
tmp = state_file ".journal.tmp"
txn_id = sprintf("%d-%d-%d", now, event_index, ++journal_txn_seq)
printf "begin\t%s\t4\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%s\t%d\t%d\n", \
txn_id, now, started_at, devices_truncated, session_started_at, \
completed_through, persistence_healthy, persistent_error_count, \
persistent_last_error_at, normalize_state_field(generation_id), \
snapshot_seq, snapshot_seq_reserved_through > tmp
for (key in journal_deleted_device) {
printf "delete_device\t%s\n", normalize_state_field(key) >> tmp
}
for (bucket = first_bucket; bucket <= last_bucket; bucket += 3600) {
if ((bucket in queries) || (bucket in blocked)) {
printf "bucket\t%d\t%d\t%d\n", bucket, queries[bucket] + 0, blocked[bucket] + 0 >> tmp
}
}
# Journal writes normally cover one completed hour. Iterate at most
# max_devices for that hour instead of scanning every retained device-hour
# entry (up to max_devices * retention_hours) on low-end routers. Identity
# migration is the exception: its target MAC receives the full retained
# history in the same transaction as the old ip:* tombstone.
cutoff = hour_start(now) - ((retention_hours - 1) * 3600)
for (key in device_seen) {
wrote_device = 0
device_first = first_bucket
device_last = last_bucket
if (key in journal_full_device) {
device_first = cutoff
device_last = hour_start(now)
}
for (bucket = device_first; bucket <= device_last; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
if (!wrote_device) {
printf "device\t%s\t%s\t%s\t%s\t%s\t%s\n", \
normalize_state_field(key), \
normalize_state_field(device_mac[key]), \
normalize_state_field(device_ip[key]), \
normalize_state_field(device_hostname[key]), \
normalize_state_field(device_client_id[key]), \
normalize_state_field(device_duid[key]) >> tmp
wrote_device = 1
}
printf "device_bucket\t%s\t%d\t%d\t%d\n", \
normalize_state_field(key), bucket, \
device_hour_queries[composite] + 0, \
device_hour_blocked[composite] + 0 >> tmp
}
}
printf "commit\t%s\n", txn_id >> tmp
close(tmp)
if (!append_file(tmp, persistent_journal_file)) {
system("rm -f " shell_quote(tmp))
return 0
}
system("rm -f " shell_quote(tmp))
clear_journal_pending()
return 1
}
function flush_completed_journal(now, current_bucket, target_bucket, cutoff, first_bucket) {
current_bucket = hour_start(now)
target_bucket = current_bucket - 3600
cutoff = current_bucket - ((retention_hours - 1) * 3600)
first_bucket = last_journal_completed_bucket + 3600
if (last_journal_completed_bucket <= 0 || first_bucket < cutoff) {
first_bucket = cutoff
}
if (target_bucket < first_bucket && !journal_has_pending_deletes() && !journal_has_pending_full_devices()) {
return 1
}
if (!write_journal_transaction(now, first_bucket, target_bucket, target_bucket)) {
return 0
}
if (target_bucket >= first_bucket) {
last_journal_completed_bucket = target_bucket
}
return 1
}
function flush_current_journal(now, current_bucket) {
current_bucket = hour_start(now)
return write_journal_transaction(now, current_bucket, current_bucket, last_journal_completed_bucket)
}
function maybe_compact_persistent(now, previous_compacted) {
if (persistent_state_file == "" || persistent_journal_file == "" || persistent_compact_interval <= 0) {
return 1
}
if (next_compact_at <= 0 || now < next_compact_at) {
return 1
}
previous_compacted = persistent_compacted_at
persistent_compacted_at = now
if (!save_state_file(persistent_state_file, now)) {
persistent_compacted_at = previous_compacted
persistent_error_count++
persistent_last_error_at = now
next_compact_at = now + ((persistent_retry_interval > 3600) ? persistent_retry_interval : 3600)
return 0
}
# The base snapshot is already complete. If journal truncation is interrupted,
# startup ignores committed transactions whose updated_at is not newer than
# the base snapshot, preventing retained absolute upserts from rolling it back.
if (!clear_journal_file(persistent_journal_file)) {
persistent_error_count++
persistent_last_error_at = now
}
schedule_next_compaction(now)
return 1
}
function save_persistent_snapshot(now, previous_updated) {
previous_updated = persistent_updated_at
persistent_updated_at = now
persistence_healthy = 1
if (!save_state_file(persistent_state_file, now)) {
persistent_updated_at = previous_updated
persistence_healthy = 0
persistent_error_count++
persistent_last_error_at = now
next_persistent_at = now + persistent_retry_interval
return 0
}
persistence_healthy = 1
return 1
}
function maybe_save_persistent(now, force, previous_updated, ok) {
if (persistent_state_file == "" && persistent_journal_file == "") {
return 1
}
if (!force && now < next_persistent_at) {
return 1
}
# Compatibility fallback for callers that do not configure a journal.
if (persistent_journal_file == "") {
if (!save_persistent_snapshot(now)) {
return 0
}
schedule_next_persistent(now)
return 1
}
previous_updated = persistent_updated_at
persistence_healthy = 1
ok = flush_completed_journal(now)
if (ok && force) {
ok = flush_current_journal(now)
}
if (!ok) {
persistent_updated_at = previous_updated
persistence_healthy = 0
persistent_error_count++
persistent_last_error_at = now
next_persistent_at = now + persistent_retry_interval
return 0
}
persistent_updated_at = now
persistence_healthy = 1
if (!force) {
maybe_compact_persistent(now)
}
schedule_next_persistent(now)
return 1
}
@@ -0,0 +1,266 @@
# SafeShield statistics: JSON snapshots and persistence scheduling.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function statistics_first_hour(now, current_hour, cutoff, first_hour) {
current_hour = hour_start(now)
cutoff = current_hour - ((retention_hours - 1) * 3600)
first_hour = hour_start(started_at)
if (first_hour < cutoff) {
first_hour = cutoff
}
return first_hour
}
function is_unknown_output_device(key) {
return (key == "unknown" || (device_mac[key] == "" && key ~ /^ip:/))
}
function save_json_range(path, now, first_hour, last_hour, include_empty_devices, tmp, bucket, comma, key, identified, device_comma, identity_comma, composite, device_first_hour, device_last_hour, persistence_enabled, persistent, healthy, volatile_state, storage, persistence_mode, truncated, range_queries, range_blocked, device_range_queries, device_range_blocked, device_has_rows, unknown_has_rows, unknown_queries, unknown_blocked) {
if (path == "") {
return 1
}
tmp = path ".tmp"
if (first_hour > last_hour) {
first_hour = last_hour
}
persistence_enabled = (persistent_state_file != "" || persistent_journal_file != "") ? "true" : "false"
persistent = (persistence_enabled == "true" && persistence_healthy && persistent_updated_at > 0) ? "true" : "false"
healthy = (persistence_enabled == "true") ? (persistence_healthy ? "true" : "false") : "true"
volatile_state = (persistence_enabled == "true") ? "false" : "true"
storage = (persistence_enabled == "true") ? "tmpfs+flash" : "tmpfs"
persistence_mode = (persistent_journal_file != "") ? "journal" : ((persistent_state_file != "") ? "snapshot" : "none")
truncated = devices_truncated ? "true" : "false"
range_queries = 0
range_blocked = 0
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
range_queries += queries[bucket] + 0
range_blocked += blocked[bucket] + 0
delete unknown_hour_queries[bucket]
delete unknown_hour_blocked[bucket]
}
# IP-only identities are intentionally retained internally for a short period
# so a later DHCP/ARP/NDP observation can migrate their history to a real
# device. They are serialized as one logical "unknown" client so temporary
# IPv6 privacy addresses cannot create unbounded client records in the Hub/UI.
unknown_has_rows = 0
unknown_queries = 0
unknown_blocked = 0
for (key in device_seen) {
if (!is_unknown_output_device(key)) {
continue
}
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
unknown_has_rows = 1
unknown_hour_queries[bucket] += device_hour_queries[composite] + 0
unknown_hour_blocked[bucket] += device_hour_blocked[composite] + 0
unknown_queries += device_hour_queries[composite] + 0
unknown_blocked += device_hour_blocked[composite] + 0
}
}
printf "{\"schema\":{\"name\":\"safeshield.statistics\",\"version\":3}," > tmp
printf "\"volatile\":%s,\"storage\":\"%s\",\"persistent\":%s,", volatile_state, storage, persistent >> tmp
printf "\"persistence_enabled\":%s,\"persistence_healthy\":%s,", persistence_enabled, healthy >> tmp
printf "\"persistence_mode\":\"%s\",", persistence_mode >> tmp
printf "\"persistent_error_count\":%d,\"persistent_last_error_at\":%d,", persistent_error_count, persistent_last_error_at >> tmp
printf "\"persistent_updated_at\":%d,\"persistent_checkpoint_interval_s\":%d,", persistent_updated_at, persistent_interval >> tmp
printf "\"persistent_compacted_at\":%d,\"persistent_compact_interval_s\":%d,", persistent_compacted_at, persistent_compact_interval >> tmp
printf "\"snapshot_interval_s\":%d,", snapshot_interval >> tmp
printf "\"generation_id\":\"%s\",", json_escape(generation_id) >> tmp
printf "\"snapshot_seq\":%d,", snapshot_seq >> tmp
printf "\"source\":{\"backend\":\"dnsmasq_ubus\",\"available\":%s,", source_available ? "true" : "false" >> tmp
printf "\"instance_id\":\"%s\",\"transport_scope\":\"%s\",", \
json_escape(source_instance_id), json_escape(source_transport_scope) >> tmp
printf "\"client_capacity\":%d,\"tracked_clients\":%d,\"untracked_queries\":%d,", \
source_client_capacity, source_tracked_clients, source_untracked_queries >> tmp
printf "\"untracked_blocked\":%d,", source_untracked_blocked >> tmp
printf "\"poll_error_count\":%d,\"last_error_at\":%d},", \
source_error_count, source_last_error_at >> tmp
printf "\"started_at\":%d,\"session_started_at\":%d,\"updated_at\":%d,", started_at, session_started_at, now >> tmp
printf "\"retention_hours\":%d,", retention_hours >> tmp
printf "\"device_limit\":%d,\"devices_truncated\":%s,", max_devices, truncated >> tmp
printf "\"totals\":{\"queries\":%d,\"blocked\":%d},", range_queries, range_blocked >> tmp
printf "\"hourly\":[" >> tmp
comma = ""
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
printf "%s{\"bucket_start\":%d,\"queries\":%d,\"blocked\":%d}", comma, bucket, queries[bucket] + 0, blocked[bucket] + 0 >> tmp
comma = ","
}
printf "],\"devices\":[" >> tmp
comma = ""
for (key in device_seen) {
if (is_unknown_output_device(key)) {
continue
}
device_first_hour = device_first_bucket[key] + 0
device_last_hour = device_last_bucket[key] + 0
if (device_first_hour <= 0 || device_first_hour < first_hour) {
device_first_hour = first_hour
}
if (device_last_hour <= 0 || device_last_hour > last_hour) {
device_last_hour = last_hour
}
device_range_queries = 0
device_range_blocked = 0
device_has_rows = 0
if (device_first_hour <= device_last_hour) {
for (bucket = device_first_hour; bucket <= device_last_hour; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
device_has_rows = 1
device_range_queries += device_hour_queries[composite] + 0
device_range_blocked += device_hour_blocked[composite] + 0
}
}
if (!include_empty_devices && !device_has_rows) {
continue
}
identified = (device_mac[key] != "") ? "true" : "false"
printf "%s{\"id\":\"%s\",\"mac\":\"%s\",\"ip\":\"%s\",\"hostname\":\"%s\",\"identified\":%s,\"queries\":%d,\"blocked\":%d,\"identities\":[", \
comma, \
json_escape(key), \
json_escape(device_mac[key]), \
json_escape(device_ip[key]), \
json_escape(device_hostname[key]), \
identified, \
device_range_queries, \
device_range_blocked >> tmp
identity_comma = ""
if (device_duid[key] != "") {
printf "%s{\"type\":\"dhcpv6_duid\",\"value\":\"%s\"}", \
identity_comma, json_escape(device_duid[key]) >> tmp
identity_comma = ","
}
if (device_client_id[key] != "") {
printf "%s{\"type\":\"dhcp_client_id\",\"value\":\"%s\"}", \
identity_comma, json_escape(device_client_id[key]) >> tmp
identity_comma = ","
}
if (device_mac[key] != "") {
printf "%s{\"type\":\"mac\",\"value\":\"%s\"}", \
identity_comma, json_escape(device_mac[key]) >> tmp
}
printf "],\"hourly\":[" >> tmp
device_comma = ""
if (device_first_hour <= device_last_hour) {
for (bucket = device_first_hour; bucket <= device_last_hour; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
printf "%s{\"bucket_start\":%d,\"queries\":%d,\"blocked\":%d}", \
device_comma, bucket, device_hour_queries[composite] + 0, device_hour_blocked[composite] + 0 >> tmp
device_comma = ","
}
}
printf "]}" >> tmp
comma = ","
}
if (unknown_has_rows) {
printf "%s{\"id\":\"unknown\",\"mac\":\"\",\"ip\":\"\",\"hostname\":\"Unknown devices\",\"identified\":false,\"queries\":%d,\"blocked\":%d,\"hourly\":[", \
comma, unknown_queries, unknown_blocked >> tmp
device_comma = ""
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
if (!((bucket in unknown_hour_queries) || (bucket in unknown_hour_blocked))) {
continue
}
printf "%s{\"bucket_start\":%d,\"queries\":%d,\"blocked\":%d}", \
device_comma, bucket, unknown_hour_queries[bucket] + 0, unknown_hour_blocked[bucket] + 0 >> tmp
device_comma = ","
}
printf "]}" >> tmp
comma = ","
}
printf "]}\n" >> tmp
close(tmp)
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
delete unknown_hour_queries[bucket]
delete unknown_hour_blocked[bucket]
}
return replace_file(tmp, path)
}
function save_json(now, current_hour, first_hour) {
current_hour = hour_start(now)
first_hour = statistics_first_hour(now)
return save_json_range(json_file, now, first_hour, current_hour, 1)
}
function save_upload_json(now, current_hour, first_hour, retained_first_hour) {
if (upload_json_file == "") {
return 1
}
current_hour = hour_start(now)
retained_first_hour = statistics_first_hour(now)
first_hour = current_hour - ((upload_window_hours - 1) * 3600)
if (first_hour < retained_first_hour) {
first_hour = retained_first_hour
}
return save_json_range(upload_json_file, now, first_hour, current_hour, 0)
}
function persistence_metadata_signature() {
return persistent_updated_at SUBSEP persistence_healthy SUBSEP persistent_error_count SUBSEP \
persistent_last_error_at SUBSEP persistent_compacted_at SUBSEP last_journal_completed_bucket
}
function save_snapshot(now, force_persistent, force_snapshot, serialize_snapshot, previous_persistence_metadata, persistence_configured) {
persistence_configured = (persistent_state_file != "" || persistent_journal_file != "")
serialize_snapshot = snapshot_dirty || force_snapshot || (force_persistent && persistence_configured)
if (serialize_snapshot) {
prune_buckets(now)
recompute_totals()
if (compact_stale_provisional_devices(now) > 0) {
recompute_totals()
}
updated_at = now
if (!ensure_snapshot_sequence_reservation(now)) {
return 0
}
}
previous_persistence_metadata = persistence_metadata_signature()
maybe_save_persistent(now, force_persistent)
if (persistence_metadata_signature() != previous_persistence_metadata) {
serialize_snapshot = 1
}
if (!serialize_snapshot) {
last_snapshot = now
return 1
}
if (!save_state_file(state_file, now)) {
return 0
}
if (!save_json(now)) {
return 0
}
# The cloud-upload projection is auxiliary. A transient tmpfs write failure
# must not stop local statistics collection; the uploader will retry once a
# later snapshot is available.
if (upload_json_file != "") {
save_upload_json(now)
}
snapshot_dirty = 0
last_snapshot = now
return 1
}
@@ -0,0 +1,422 @@
# SafeShield statistics: collector lifecycle and dnsmasq event dispatch.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function record_event(query_delta, blocked_delta, client_ip, now, bucket) {
if (is_internal_statistics_client(client_ip)) {
return
}
now = current_time()
bucket = hour_start(now)
snapshot_dirty = 1
queries[bucket] += 0
blocked[bucket] += 0
if (query_delta > 0) {
queries[bucket] += query_delta
}
if (blocked_delta > 0) {
blocked[bucket] += blocked_delta
}
record_device(client_ip, query_delta, blocked_delta, now)
event_index++
if ((now - last_snapshot) >= snapshot_interval) {
save_snapshot(now, 0, 0)
}
}
function source_counter_delta(current, previous, same_epoch) {
current = numeric(current, 0)
previous = numeric(previous, 0)
if (!same_epoch || current < previous) {
return current
}
return current - previous
}
function clear_poll_clients( address) {
for (address in poll_client_seen) {
delete poll_client_seen[address]
delete poll_client_queries[address]
delete poll_client_blocked[address]
}
}
function replace_source_clients( address) {
for (address in source_client_queries) {
delete source_client_queries[address]
delete source_client_blocked[address]
}
for (address in poll_client_seen) {
source_client_queries[address] = poll_client_queries[address] + 0
source_client_blocked[address] = poll_client_blocked[address] + 0
}
}
function apply_source_snapshot( now, bucket, had_source, same_epoch, query_delta, blocked_delta, internal_queries, internal_blocked, address, client_queries_delta, client_blocked_delta) {
if (!poll_active || poll_instance_id == "") {
return
}
now = current_time()
bucket = hour_start(now)
had_source = source_initialized && !force_rebaseline
same_epoch = had_source && poll_instance_id == source_instance_id
if (!had_source) {
query_delta = 0
blocked_delta = 0
}
else {
query_delta = source_counter_delta(poll_total_queries, source_total_queries, same_epoch)
blocked_delta = source_counter_delta(poll_total_blocked, source_total_blocked, same_epoch)
}
internal_queries = 0
internal_blocked = 0
for (address in poll_client_seen) {
if (!had_source) {
client_queries_delta = 0
client_blocked_delta = 0
}
else {
client_queries_delta = source_counter_delta(poll_client_queries[address], \
source_client_queries[address], same_epoch)
client_blocked_delta = source_counter_delta(poll_client_blocked[address], \
source_client_blocked[address], same_epoch)
}
if (is_internal_statistics_client(address)) {
internal_queries += client_queries_delta
internal_blocked += client_blocked_delta
continue
}
if (client_queries_delta > 0 || client_blocked_delta > 0) {
record_device(address, client_queries_delta, client_blocked_delta, now)
}
}
query_delta -= internal_queries
blocked_delta -= internal_blocked
if (query_delta < 0) {
query_delta = 0
}
if (blocked_delta < 0) {
blocked_delta = 0
}
if (query_delta > 0 || blocked_delta > 0) {
queries[bucket] += 0
blocked[bucket] += 0
queries[bucket] += query_delta
blocked[bucket] += blocked_delta
}
source_initialized = 1
source_available = 1
source_instance_id = poll_instance_id
source_transport_scope = poll_transport_scope
source_client_capacity = poll_client_capacity
source_tracked_clients = poll_tracked_clients
source_untracked_queries = poll_untracked_queries
source_untracked_blocked = poll_untracked_blocked
source_total_queries = poll_total_queries
source_total_blocked = poll_total_blocked
replace_source_clients()
poll_active = 0
clear_poll_clients()
snapshot_dirty = 1
event_index++
if (save_snapshot(now, 0, 0) && force_rebaseline) {
if (rebaseline_file != "") {
system("rm -f " shell_quote(rebaseline_file))
}
force_rebaseline = 0
}
}
function record_source_error( now) {
now = current_time()
source_available = 0
source_error_count++
source_last_error_at = now
snapshot_dirty = 1
event_index++
save_snapshot(now, 0, 0)
}
BEGIN {
state_file = (state_file != "") ? state_file : "/tmp/safeshield/statistics/state.tsv"
json_file = (json_file != "") ? json_file : "/tmp/safeshield/statistics/statistics.json"
upload_json_file = (upload_json_file != "") ? upload_json_file : ""
persistent_state_file = (persistent_state_file != "") ? persistent_state_file : ""
persistent_journal_file = (persistent_journal_file != "") ? persistent_journal_file : ""
sequence_file = (sequence_file != "") ? sequence_file : ""
lease_file = (lease_file != "") ? lease_file : "/tmp/dhcp.leases"
arp_file = (arp_file != "") ? arp_file : "/proc/net/arp"
ipv6_neigh_file = (ipv6_neigh_file != "") ? ipv6_neigh_file : ""
ipv6_neigh_command = (ipv6_neigh_command != "") ? ipv6_neigh_command : ""
ipv6_identity_file = (ipv6_identity_file != "") ? ipv6_identity_file : ""
ipv6_identity_command = (ipv6_identity_command != "") ? ipv6_identity_command : ""
snapshot_interval = numeric(snapshot_interval, 60)
retention_hours = numeric(retention_hours, 168)
upload_window_hours = numeric(upload_window_hours, 2)
lease_refresh_interval = numeric(lease_refresh_interval, 60)
identity_cache_ttl = numeric(identity_cache_ttl, 60)
provisional_retention_hours = numeric(provisional_retention_hours, 6)
persistent_interval = numeric(persistent_interval, 3600)
persistent_retry_interval = numeric(persistent_retry_interval, 300)
persistent_compact_interval = numeric(persistent_compact_interval, 604800)
snapshot_seq_reservation_size = numeric(snapshot_seq_reservation_size, 4096)
max_devices = numeric(max_devices, 128)
fixed_now = numeric(fixed_now, 0)
fixed_step = numeric(fixed_step, 0)
generation_seed = normalize_state_field(generation_seed)
force_rebaseline = numeric(force_rebaseline, 0) ? 1 : 0
rebaseline_file = (rebaseline_file != "") ? rebaseline_file : ""
if (generation_seed == "*") {
generation_seed = ""
}
event_index = 0
snapshot_dirty = 0
device_count = 0
devices_truncated = 0
last_lease_refresh = 0
loaded_state = 0
loaded_from_persistent = 0
state_schema_version = 1
snapshot_seq = 0
snapshot_seq_reserved_through = 0
persistent_updated_at = 0
persistent_compacted_at = 0
last_journal_completed_bucket = 0
journal_txn_seq = 0
journal_replaying = 0
persistence_healthy = (persistent_state_file != "" || persistent_journal_file != "") ? 1 : 0
persistent_error_count = 0
persistent_last_error_at = 0
source_initialized = 0
source_available = 0
source_instance_id = ""
source_transport_scope = ""
source_client_capacity = 0
source_tracked_clients = 0
source_untracked_queries = 0
source_untracked_blocked = 0
source_total_queries = 0
source_total_blocked = 0
source_error_count = 0
source_last_error_at = 0
poll_active = 0
if (snapshot_interval < 1) {
snapshot_interval = 60
}
if (retention_hours < 1) {
retention_hours = 168
}
if (upload_window_hours < 1 || upload_window_hours > retention_hours) {
upload_window_hours = (retention_hours < 2) ? retention_hours : 2
}
if (lease_refresh_interval < 1) {
lease_refresh_interval = 60
}
if (identity_cache_ttl < 1) {
identity_cache_ttl = 60
}
if (identity_cache_ttl > lease_refresh_interval) {
identity_cache_ttl = lease_refresh_interval
}
if (provisional_retention_hours < 1) {
provisional_retention_hours = 6
}
if (provisional_retention_hours > retention_hours) {
provisional_retention_hours = retention_hours
}
if (persistent_interval < 60) {
persistent_interval = 3600
}
if (persistent_retry_interval < 60) {
persistent_retry_interval = 300
}
if (persistent_compact_interval < 3600) {
persistent_compact_interval = 604800
}
if (snapshot_seq_reservation_size < 64) {
snapshot_seq_reservation_size = 4096
}
if (max_devices < 1) {
max_devices = 128
}
tmp_state_updated_at = state_file_updated_at(state_file)
persistent_state_updated_at = state_file_updated_at(persistent_state_file)
persistent_journal_updated_at = journal_file_updated_at(persistent_journal_file)
persistent_combined_updated_at = persistent_state_updated_at
if (persistent_journal_updated_at > persistent_combined_updated_at) {
persistent_combined_updated_at = persistent_journal_updated_at
}
tmp_state_snapshot_seq = (tmp_state_updated_at >= 0) ? state_file_snapshot_seq(state_file) : -1
persistent_state_snapshot_seq = (persistent_state_updated_at >= 0) ? state_file_snapshot_seq(persistent_state_file) : -1
persistent_journal_snapshot_seq = (persistent_journal_updated_at >= 0) ? journal_file_snapshot_seq(persistent_journal_file) : -1
tmp_state_generation_id = (tmp_state_updated_at >= 0) ? state_file_generation_id(state_file) : ""
persistent_state_generation_id = (persistent_state_updated_at >= 0) ? state_file_generation_id(persistent_state_file) : ""
persistent_journal_generation_id = (persistent_journal_updated_at >= 0) ? journal_file_generation_id(persistent_journal_file) : ""
persistent_combined_snapshot_seq = persistent_state_snapshot_seq
persistent_combined_generation_id = persistent_state_generation_id
if (persistent_journal_snapshot_seq > persistent_combined_snapshot_seq) {
persistent_combined_snapshot_seq = persistent_journal_snapshot_seq
persistent_combined_generation_id = persistent_journal_generation_id
}
else if (persistent_combined_generation_id == "" && persistent_journal_generation_id != "") {
persistent_combined_generation_id = persistent_journal_generation_id
}
# snapshot_seq is meaningful only inside one generation. A live tmpfs state
# from a different generation must not lose to a numerically larger sequence
# left by an older flash generation. Within the same generation, sequence is
# the primary freshness signal so NTP clock corrections cannot reorder state.
prefer_persistent_state = 0
if (tmp_state_updated_at >= 0 && persistent_combined_updated_at >= 0 && \
tmp_state_generation_id != "" && persistent_combined_generation_id != "" && \
tmp_state_generation_id != persistent_combined_generation_id) {
prefer_persistent_state = 0
}
else if (tmp_state_snapshot_seq >= 0 || persistent_combined_snapshot_seq >= 0) {
prefer_persistent_state = (persistent_combined_snapshot_seq > tmp_state_snapshot_seq)
}
else {
prefer_persistent_state = (persistent_combined_updated_at > tmp_state_updated_at)
}
if (prefer_persistent_state && persistent_combined_updated_at >= 0) {
loaded_from_persistent = 1
if (persistent_state_updated_at >= 0) {
load_state_file(persistent_state_file)
}
if (persistent_journal_updated_at >= 0) {
load_journal_file(persistent_journal_file, persistent_state_updated_at, snapshot_seq)
}
}
else if (tmp_state_updated_at >= 0) {
load_state_file(state_file)
}
else if (persistent_combined_updated_at >= 0) {
loaded_from_persistent = 1
if (persistent_state_updated_at >= 0) {
load_state_file(persistent_state_file)
}
if (persistent_journal_updated_at >= 0) {
load_journal_file(persistent_journal_file, persistent_state_updated_at, snapshot_seq)
}
}
if (persistent_state_file == "" && persistent_journal_file == "") {
persistent_updated_at = 0
persistent_compacted_at = 0
last_journal_completed_bucket = 0
persistence_healthy = 1
persistent_error_count = 0
persistent_last_error_at = 0
}
# started_at identifies the lifetime of the currently retained statistics
# dataset. It survives collector restarts whenever state can be restored.
if (started_at <= 0) {
started_at = current_time()
}
# generation_id is the stable identity for that dataset. The stats daemon
# supplies a fresh candidate on every process start, but restored state wins.
if (generation_id == "") {
generation_id = (generation_seed != "") ? generation_seed : sprintf("legacy-%d", started_at)
}
durable_snapshot_seq_reserved = load_sequence_reservation(sequence_file, generation_id)
if (durable_snapshot_seq_reserved > snapshot_seq_reserved_through) {
snapshot_seq_reserved_through = durable_snapshot_seq_reserved
}
# A tmpfs restart can continue with the exact last emitted sequence because
# save_state_file() precedes save_json(). After a reboot restores flash-backed
# state, skip the whole durable reservation so a sequence already observed by
# Hub can never be reused even when the last flash checkpoint was older.
if (loaded_from_persistent && snapshot_seq_reserved_through > snapshot_seq) {
snapshot_seq = snapshot_seq_reserved_through
}
# session_started_at is process-scoped and intentionally changes every time
# the collector starts, even when generation_id and started_at are restored.
session_started_at = current_time()
migrate_legacy_device_totals(current_time())
refresh_leases(current_time(), 1)
last_snapshot = updated_at
if (last_snapshot <= 0) {
last_snapshot = session_started_at
}
if (persistent_state_file != "" || persistent_journal_file != "") {
if (last_journal_completed_bucket <= 0 && persistent_updated_at > 0) {
last_journal_completed_bucket = hour_start(persistent_updated_at) - 3600
}
if (persistent_compacted_at <= 0) {
persistent_compacted_at = (persistent_updated_at > 0) ? persistent_updated_at : current_time()
}
schedule_next_persistent(current_time())
schedule_next_compaction(current_time())
}
else {
next_persistent_at = 0
next_compact_at = 0
}
save_snapshot(current_time(), 0, 1)
}
$1 == "snapshot" && NF >= 9 {
clear_poll_clients()
poll_active = 1
poll_instance_id = $2
poll_transport_scope = $3
poll_client_capacity = numeric($4, 0)
poll_tracked_clients = numeric($5, 0)
poll_untracked_queries = numeric($6, 0)
poll_untracked_blocked = numeric($7, 0)
poll_total_queries = numeric($8, 0)
poll_total_blocked = numeric($9, 0)
next
}
$1 == "client" && NF >= 4 && poll_active {
poll_client_seen[$2] = 1
poll_client_queries[$2] = numeric($3, 0)
poll_client_blocked[$2] = numeric($4, 0)
next
}
$1 == "commit" && poll_active {
apply_source_snapshot()
next
}
$1 == "error" {
poll_active = 0
clear_poll_clients()
record_source_error()
next
}
/dnsmasq\[[0-9]+\]:/ {
if ($0 ~ / query\[[^]]+\] .* from /) {
record_event(1, 0, extract_query_client($0))
next
}
if ($0 ~ / config .* is (0\.0\.0\.0|::)([[:space:]]|$)/) {
record_event(0, 1, extract_extra_client($0))
next
}
}
END {
save_snapshot(current_time(), 1, 0)
}
@@ -0,0 +1,92 @@
'use strict';
let fs = require('fs');
function empty_obj() {
return {
data: {},
warnings: [],
errors: []
};
}
function read_json(path) {
let raw = fs.readfile(path);
if (!raw)
return empty_obj();
try {
let obj = json(raw);
if (!obj || type(obj) != 'object')
obj = {};
if (!obj.data || type(obj.data) != 'object')
obj.data = {};
if (!obj.warnings || type(obj.warnings) != 'array')
obj.warnings = [];
if (!obj.errors || type(obj.errors) != 'array')
obj.errors = [];
return obj;
}
catch (e) {
return empty_obj();
}
}
function usage() {
print('usage: ucode status-store.uc <path> <command> [args...]\n');
return 1;
}
let path = ARGV[0];
let cmd = ARGV[1];
if (!path || !cmd)
exit(usage());
let obj = read_json(path);
if (cmd == 'reset') {
obj = empty_obj();
}
else if (cmd == 'set') {
let key = ARGV[2];
let value = ARGV[3];
if (!key)
exit(2);
obj.data[key] = value;
}
else if (cmd == 'add_error') {
let code = ARGV[2];
if (!code)
exit(2);
push(obj.errors, { code: code });
}
else if (cmd == 'add_warning') {
let code = ARGV[2];
if (!code)
exit(2);
push(obj.warnings, { code: code });
}
else if (cmd == 'clear_messages') {
obj.errors = [];
obj.warnings = [];
}
else if (cmd == 'dump') {
}
else {
exit(usage());
}
print(sprintf('%J\n', obj));
exit(0);
+302
View File
@@ -0,0 +1,302 @@
# shellcheck shell=ash
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
readonly SS_STATUS_STORE='/usr/lib/safeshield/status-store.uc'
ss_status_exec() {
ucode "${SS_STATUS_STORE}" "${RUNNING_STATUS_FILE}" "$@"
}
ss_status_apply() {
local tmp="${RUNNING_STATUS_FILE}.tmp"
mkdir -p "${RUNNING_STATUS_FILE%/*}" || return 1
ss_status_exec "$@" >"${tmp}" || {
rm -f "${tmp}"
return 1
}
[ -s "${tmp}" ] || {
rm -f "${tmp}"
return 1
}
mv -f "${tmp}" "${RUNNING_STATUS_FILE}" || {
rm -f "${tmp}"
return 1
}
return 0
}
ss_now() {
date +%s
}
ss_status_set_now() {
ss_status_set "$1" "$(ss_now)"
}
ss_status_reset_health_fields() {
ss_status_set health_dnsmasq_binary ""
ss_status_set health_dnsmasq_version ""
ss_status_set health_dnsmasq_features ""
ss_status_set health_dnsmasq_confdir ""
ss_status_set health_dnsmasq_initial_restart ""
ss_status_set health_dnsmasq_final_restart ""
ss_status_set health_dns_runtime ""
ss_status_set health_blocklist_verify ""
ss_status_set health_min_valid_line_count ""
ss_status_set health_max_file_size ""
ss_status_set health_api_resolve ""
ss_status_set health_artifact_download ""
ss_status_set health_artifact_sha256 ""
ss_status_set dnsmasq_version ""
ss_status_set dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION:-2.93}"
}
ss_status_reset_blocklist_fields() {
ss_status_set blocklist_installed "0"
ss_status_set blocklist_file_size_kb "0"
ss_status_set blocklist_verification_ok "0"
ss_status_set blocklist_test_domain ""
ss_status_set blocklist_test_domain_sample_count "0"
ss_status_set blocklist_test_domain_success_count "0"
ss_status_set blocklist_test_domains ""
ss_status_set blocklist_backup_available "0"
}
ss_status_reset_artifact_fields() {
ss_status_set license_plan ""
ss_status_set license_status ""
ss_status_set physical_fingerprint ""
ss_status_set fingerprint_version ""
ss_status_set identity_provider ""
ss_status_set identity_source ""
ss_status_set identity_strength ""
ss_status_set identity_profile ""
ss_status_set device_code ""
ss_status_set device_code_source ""
ss_status_set installation_id ""
ss_status_set device_profile ""
ss_status_set artifact_tier ""
ss_status_set artifact_version ""
ss_status_set artifact_sha256 ""
ss_status_set artifact_unique_domains "0"
ss_status_set artifact_rules "0"
ss_status_set artifact_download_url_present "0"
ss_status_set artifact_source_count "0"
ss_status_set artifact_block_source_count "0"
ss_status_set artifact_allow_source_count "0"
}
ss_status_mark_failure() {
local code="$1"
ss_status_set status "error"
ss_status_set_now last_failure
ss_status_set last_result "error"
ss_status_set last_error_code "$code"
ss_status_add_error "$code"
}
ss_status_mark_success() {
local interval="${1:-0}"
local now
now="$(ss_now)"
ss_status_apply clear_messages >/dev/null 2>&1 || true
ss_status_set status "ready"
ss_status_set stage "done"
ss_status_set last_success "$now"
ss_status_set last_result "success"
ss_status_set last_error_code ""
if is_valid_integer "$interval" && [ "$interval" -gt 0 ] 2>/dev/null; then
ss_status_set next_refresh_at "$((now + interval))"
fi
}
ss_status_prepare_refresh() {
local last_success last_local_apply last_local_apply_failure
last_success="$(json get last_success 2>/dev/null)"
last_local_apply="$(json get last_local_apply 2>/dev/null)"
last_local_apply_failure="$(json get last_local_apply_failure 2>/dev/null)"
is_valid_integer "$last_success" || last_success=0
is_valid_integer "$last_local_apply" || last_local_apply=0
is_valid_integer "$last_local_apply_failure" || last_local_apply_failure=0
ss_status_reset
ss_status_set last_success "$last_success"
ss_status_set last_local_apply "$last_local_apply"
ss_status_set last_local_apply_failure "$last_local_apply_failure"
}
ss_status_set() {
local key="$1"
local value="$2"
[ -n "$key" ] || return 0
ss_status_apply set "$key" "$value" >/dev/null 2>&1 || true
}
ss_status_add_error() {
local code="$1"
[ -n "$code" ] || return 0
ss_status_apply add_error "$code" >/dev/null 2>&1 || true
}
ss_status_add_warning() {
local code="$1"
[ -n "$code" ] || return 0
ss_status_apply add_warning "$code" >/dev/null 2>&1 || true
}
ss_status_reset() {
rm -f "${RUNNING_STATUS_FILE}" "${RUNNING_STATUS_FILE}.tmp"
ss_status_apply reset >/dev/null 2>&1 || return 1
ss_status_set status "idle"
ss_status_set stage ""
ss_status_set valid_line_count "0"
ss_status_set last_result "idle"
ss_status_set last_error_code ""
ss_status_set last_attempt "0"
ss_status_set last_success "0"
ss_status_set last_failure "0"
ss_status_set last_local_apply "0"
ss_status_set last_local_apply_failure "0"
ss_status_set next_refresh_at "0"
ss_status_reset_health_fields
ss_status_reset_blocklist_fields
ss_status_reset_artifact_fields
ss_status_apply clear_messages >/dev/null 2>&1 || true
}
ss_blocklist_tmp_path() {
printf '%s/.safeshield.blocklist.tmp.%s' "${SS_DNSMASQ_DIR}" "$$"
}
ss_backup_tmp_path() {
printf '%s.tmp.%s' "${SS_PREV_BLOCKLIST_GZ}" "$$"
}
ss_sync_path() {
local path="$1"
[ -n "$path" ] || return 0
sync -f "$path" 2>/dev/null && return 0
sync "$path" 2>/dev/null && return 0
sync 2>/dev/null || true
}
ss_install_blocklist_atomic() {
local src="$1"
local dst="$2"
local dir
[ -f "$src" ] || return 1
[ -n "$dst" ] || return 1
dir="${dst%/*}"
ss_sync_path "$src"
mv -f "$src" "$dst" || return 1
ss_sync_path "$dir"
return 0
}
ss_export_existing_blocklist() {
local tmp
[ -f "${SS_BLOCKLIST_FILE}" ] || return 1
command_exists gzip || return 1
tmp="$(ss_backup_tmp_path)" || return 1
gzip -c "${SS_BLOCKLIST_FILE}" >"$tmp" || {
rm -f "$tmp"
return 1
}
ss_sync_path "$tmp"
mv -f "$tmp" "${SS_PREV_BLOCKLIST_GZ}" || {
rm -f "$tmp"
return 1
}
ss_sync_path "${SS_PREV_BLOCKLIST_GZ%/*}"
return 0
}
ss_restore_previous_blocklist() {
local tmp
[ -f "${SS_PREV_BLOCKLIST_GZ}" ] || return 1
command_exists gunzip || return 1
tmp="$(ss_blocklist_tmp_path)" || return 1
gunzip -c "${SS_PREV_BLOCKLIST_GZ}" >"$tmp" || {
rm -f "$tmp"
return 1
}
ss_install_blocklist_atomic "$tmp" "${SS_BLOCKLIST_FILE}" || {
rm -f "$tmp"
return 1
}
return 0
}
ss_refresh_lock_open() {
mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1
eval "exec ${SS_REFRESH_LOCK_FD}>\"${SS_REFRESH_LOCK}\"" || return 1
flock -n "${SS_REFRESH_LOCK_FD}" || {
eval "exec ${SS_REFRESH_LOCK_FD}>&-"
return 1
}
}
ss_refresh_lock_open_wait() {
local timeout="${1:-120}"
local waited=0
is_valid_integer "$timeout" || timeout=120
mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1
eval "exec ${SS_REFRESH_LOCK_FD}>\"${SS_REFRESH_LOCK}\"" || return 1
while ! flock -n "${SS_REFRESH_LOCK_FD}"; do
if [ "$waited" -ge "$timeout" ] 2>/dev/null; then
eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true
return 1
fi
ss_should_stop && {
eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true
return 130
}
sleep 1
waited=$((waited + 1))
done
return 0
}
ss_refresh_lock_close() {
flock -u "${SS_REFRESH_LOCK_FD}" 2>/dev/null || true
eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true
}
+137
View File
@@ -0,0 +1,137 @@
# shellcheck shell=ash
readonly LOCK_FD=309
readonly RUNNING_STATUS_LOCK="/var/lock/${PKG_NAME}.lock"
readonly RUNNING_STATUS_FILE="/dev/shm/${PKG_NAME}.status.json"
command_exists() {
command -v "$1" >/dev/null 2>&1
}
ss_mask_secret() {
local value="$1"
local len
[ -n "$value" ] || {
printf '%s' ''
return 0
}
len="${#value}"
if [ "$len" -le 8 ] 2>/dev/null; then
printf '%s' '********'
else
printf '%s****%s' "${value%${value#????}}" "${value#${value%????}}"
fi
}
is_valid_integer() {
case "$1" in
'' | *[!0-9]*) return 1 ;;
esac
[ "$1" -ge 0 ] 2>/dev/null
}
is_greater_equal() {
[ "$#" -eq 2 ] || return 2
[ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ]
}
json_lock_open() {
mkdir -p "${RUNNING_STATUS_LOCK%/*}" || return 1
eval "exec ${LOCK_FD}>\"${RUNNING_STATUS_LOCK}\"" || return 1
flock -x "${LOCK_FD}" || {
eval "exec ${LOCK_FD}>&-"
return 1
}
}
json_lock_close() {
flock -u "${LOCK_FD}" 2>/dev/null || true
eval "exec ${LOCK_FD}>&-" 2>/dev/null || true
}
_json_init_data_object() {
mkdir -p "${RUNNING_STATUS_FILE%/*}" || return 1
if ! json_load_file "$RUNNING_STATUS_FILE" 2>/dev/null; then
json_init
fi
json_select data 2>/dev/null || {
json_add_object data
json_close_object
json_select data 2>/dev/null
}
}
_json_get() {
local key="$1"
local out=''
json_get_var out "$key" 2>/dev/null || true
printf '%s' "$out"
}
_json_set() {
local key="$1"
local value="$2"
json_add_string "$key" "$value"
json_select .. 2>/dev/null || true
json_dump >"${RUNNING_STATUS_FILE}"
}
_json_add_message() {
local kind="$1"
local value="$2"
local arr="${kind}s"
json_select .. 2>/dev/null || true
json_select "$arr" 2>/dev/null || {
json_add_array "$arr"
json_close_array
json_select "$arr" 2>/dev/null || return 1
}
json_add_object ""
json_add_string code "$value"
json_add_string info "$value"
json_close_object
json_select .. 2>/dev/null || true
json_dump >"${RUNNING_STATUS_FILE}"
}
json() {
local action="$1"
local key="$2"
shift 2 2>/dev/null
local value="$*"
local rc=0
json_lock_open || return 1
_json_init_data_object || rc=1
if [ "$rc" -eq 0 ]; then
case "${action}:${key}" in
get:*)
_json_get "$key" || rc=1
;;
set:*)
_json_set "$key" "$value" || rc=1
;;
add:error | add:warning)
_json_add_message "$key" "$value" || rc=1
;;
*)
rc=1
;;
esac
fi
json_cleanup 2>/dev/null || true
json_lock_close
return "$rc"
}
+258
View File
@@ -0,0 +1,258 @@
#!/bin/sh
# shellcheck shell=ash
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
LC_ALL=C
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
# shellcheck disable=SC1091
. /lib/functions.sh
# shellcheck disable=SC1091
. /usr/lib/safeshield/core.sh
readonly SS_REFRESHD_LOCK='/var/lock/safeshield-refreshd.lock'
readonly SS_REFRESHD_LOCK_FD=308
ss_should_terminate=0
ss_sleep_pid=''
refreshd_lock() {
mkdir -p "${SS_REFRESHD_LOCK%/*}" || return 1
eval "exec ${SS_REFRESHD_LOCK_FD}>\"${SS_REFRESHD_LOCK}\"" || return 1
flock -n "${SS_REFRESHD_LOCK_FD}" || return 1
}
refreshd_unlock() {
flock -u "${SS_REFRESHD_LOCK_FD}" 2>/dev/null || true
eval "exec ${SS_REFRESHD_LOCK_FD}>&-" 2>/dev/null || true
}
handle_term() {
ss_should_terminate=1
if [ -n "$ss_sleep_pid" ]; then
kill "$ss_sleep_pid" 2>/dev/null || true
fi
}
sleep_seconds() {
local seconds="$1"
local rc=0
[ "$ss_should_terminate" -eq 1 ] && return 1
[ "$seconds" -gt 0 ] 2>/dev/null || return 0
sleep "$seconds" &
ss_sleep_pid=$!
wait "$ss_sleep_pid" 2>/dev/null || rc=$?
ss_sleep_pid=''
[ "$ss_should_terminate" -eq 1 ] && return 1
[ "$rc" -eq 0 ]
}
wait_for_wan_if_needed() {
local require_wan
require_wan="$(ss_config_get config require_wan 1)"
case "$require_wan" in
1)
ubus -t 30 wait_for network.interface.wan 2>/dev/null || true
;;
esac
}
status_epoch() {
local key="$1"
local value
value="$(json get "$key" 2>/dev/null)"
is_valid_integer "$value" || value=0
printf '%s\n' "$value"
}
schedule_base_epoch() {
local now="$1"
local last_success last_attempt base=0
last_success="$(status_epoch last_success)"
last_attempt="$(status_epoch last_attempt)"
if [ "$last_success" -gt 0 ] 2>/dev/null && [ "$last_success" -le "$now" ] 2>/dev/null; then
base="$last_success"
fi
# A failed or currently-running refresh must also move the retry window
# forward so refreshd cannot enter an immediate retry loop. A successful
# refresh is naturally newer than its attempt timestamp and wins here.
if [ "$last_attempt" -gt "$base" ] 2>/dev/null && [ "$last_attempt" -le "$now" ] 2>/dev/null; then
base="$last_attempt"
fi
printf '%s\n' "$base"
}
has_recent_success() {
local interval="$1"
local now last_success age
now="$(date +%s)"
last_success="$(status_epoch last_success)"
[ "$last_success" -gt 0 ] 2>/dev/null || return 1
[ "$last_success" -le "$now" ] 2>/dev/null || return 1
age=$((now - last_success))
[ "$age" -lt "$interval" ] 2>/dev/null
}
set_next_refresh_epoch() {
local epoch="$1"
is_valid_integer "$epoch" || epoch=0
ss_status_set next_refresh_at "$epoch"
}
clear_next_refresh_at() {
ss_status_set next_refresh_at "0"
}
wait_until_refresh_due() {
local interval="$1"
local fallback_epoch="$2"
local now base due remaining
while :; do
[ "$ss_should_terminate" -eq 1 ] && return 1
now="$(date +%s)"
base="$(schedule_base_epoch "$now")"
if [ "$base" -le 0 ] 2>/dev/null; then
base="$fallback_epoch"
fi
due=$((base + interval))
remaining=$((due - now))
if [ "$remaining" -le 0 ] 2>/dev/null; then
clear_next_refresh_at
return 0
fi
set_next_refresh_epoch "$due"
sleep_seconds "$remaining" || return 1
# A manual refresh may have completed while refreshd was sleeping.
# Recalculate from the latest last_success/last_attempt before running.
done
}
main() {
local interval
local boot_delay
local refresh_on_boot
local schedule_anchor
refreshd_lock || exit 0
ss_load_config || exit 1
if [ ! -s "${RUNNING_STATUS_FILE}" ]; then
ss_status_reset || true
fi
ss_sync_blocklist_status
ss_status_set status "idle"
ss_status_set stage "starting"
interval="$(ss_config_get config refresh_interval_s 28800)"
boot_delay="$(ss_config_get config boot_start_delay_s 30)"
refresh_on_boot="$(ss_config_get config refresh_on_boot 1)"
is_valid_integer "$interval" || interval=28800
[ "$interval" -gt 0 ] 2>/dev/null || interval=28800
is_valid_integer "$boot_delay" || boot_delay=30
wait_for_wan_if_needed
ss_status_set stage "boot_delay"
set_next_refresh_epoch "$(($(date +%s) + boot_delay))"
sleep_seconds "$boot_delay" || exit 0
if [ "$refresh_on_boot" = "1" ] && [ "${ss_enabled}" = "1" ]; then
if has_recent_success "$interval"; then
log_info "Skipping boot refresh because a recent refresh already succeeded"
ss_status_set status "idle"
ss_status_set stage "boot_refresh_skipped"
else
ss_status_set status "running"
ss_status_set stage "boot_refresh"
clear_next_refresh_at
safeshield_force_download
fi
fi
schedule_anchor="$(date +%s)"
while :; do
ss_load_config || {
schedule_anchor="$(date +%s)"
sleep_seconds 5 || break
continue
}
interval="$(ss_config_get config refresh_interval_s 28800)"
is_valid_integer "$interval" || interval=28800
[ "$interval" -gt 0 ] 2>/dev/null || interval=28800
if [ "${ss_enabled}" != "1" ]; then
ss_status_set status "disabled"
ss_status_set stage "disabled"
clear_next_refresh_at
sleep_seconds "$interval" || break
schedule_anchor="$(date +%s)"
continue
fi
ss_status_set status "idle"
ss_status_set stage "scheduled_wait"
wait_until_refresh_due "$interval" "$schedule_anchor" || break
ss_load_config || {
schedule_anchor="$(date +%s)"
continue
}
if [ "${ss_enabled}" != "1" ]; then
ss_status_set status "disabled"
ss_status_set stage "disabled"
clear_next_refresh_at
schedule_anchor="$(date +%s)"
continue
fi
wait_for_wan_if_needed || true
ss_status_set status "running"
ss_status_set stage "scheduled_refresh"
clear_next_refresh_at
safeshield_force_download
schedule_anchor="$(date +%s)"
done
ss_load_config >/dev/null 2>&1 || true
if [ "${ss_enabled:-0}" = "1" ]; then
ss_status_set status "idle"
ss_status_set stage "stopped"
else
ss_status_set status "disabled"
ss_status_set stage "disabled"
fi
clear_next_refresh_at
}
trap "handle_term" TERM INT
trap "refreshd_unlock; clear_next_refresh_at" EXIT
main "$@"
+496
View File
@@ -0,0 +1,496 @@
#!/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 license'
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 [ -z "${ss_license_key:-}" ]; then
ss_statistics_upload_denied || ss_statistics_disable_cloud_upload >/dev/null 2>&1 || true
fi
if ss_statistics_upload_denied; then
upload_pending_clear
if [ -n "${ss_license_key:-}" ] && 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 resolve 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
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/ucode
'use strict';
const ubus = require('ubus');
function clean(value) {
return replace(sprintf('%s', value || ''), /[\t\r\n]/g, '_');
}
function main() {
let conn = ubus.connect();
if (!conn) {
return;
}
let data = conn.call('dhcp', 'ipv6leases', {});
if (type(data) != 'object' || type(data.device) != 'object') {
conn.disconnect();
return;
}
for (let _, device in data.device) {
if (type(device) != 'object' || type(device.leases) != 'array') {
continue;
}
for (let lease in device.leases) {
if (type(lease) != 'object') {
continue;
}
let duid = lc(clean(lease.duid));
let hostname = clean(lease.hostname);
let addresses = lease['ipv6-addr'];
if (!duid || type(addresses) != 'array') {
continue;
}
for (let address in addresses) {
if (type(address) != 'object') {
continue;
}
let ip = clean(address.address);
if (ip) {
printf('%s\t%s\t%s\n', ip, duid, hostname);
}
}
}
}
conn.disconnect();
}
main();
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/ucode
'use strict';
let ubus = require('ubus').connect();
function token(value) {
let text = sprintf('%s', value == null ? '' : value);
return replace(text, /[\t\r\n]/g, '');
}
function uint_or_null(value) {
if (value == null) {
return null;
}
let number = int(value);
return (type(number) == 'int' && number >= 0) ? number : null;
}
function optional_uint(value) {
let number = uint_or_null(value);
return number == null ? 0 : number;
}
if (!ubus) {
exit(1);
}
let data = ubus.call('dnsmasq', 'safeshield_stats', {});
if (type(data) != 'object' || uint_or_null(data.schema) != 1) {
exit(1);
}
let instance_id = token(data.instance_id);
if (!instance_id || type(data.totals) != 'object' || type(data.clients) != 'array') {
exit(1);
}
let total_queries = uint_or_null(data.totals.queries);
let total_blocked = uint_or_null(data.totals.blocked);
if (total_queries == null || total_blocked == null) {
exit(1);
}
let transport_scope = token(data.transport_scope || 'unknown');
let client_capacity = optional_uint(data.client_capacity);
let tracked_clients = optional_uint(data.tracked_clients);
let untracked_queries = optional_uint(data.untracked_queries);
let untracked_blocked = optional_uint(data.untracked_blocked);
let normalized_clients = [];
for (let client in data.clients) {
if (type(client) != 'object') {
exit(1);
}
let address = token(client.address);
let queries = uint_or_null(client.queries);
let blocked = uint_or_null(client.blocked);
if (!address || queries == null || blocked == null) {
exit(1);
}
push(normalized_clients, {
address: address,
queries: queries,
blocked: blocked
});
}
printf('snapshot\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\n',
instance_id,
transport_scope,
client_capacity,
tracked_clients,
untracked_queries,
untracked_blocked,
total_queries,
total_blocked);
for (let client in normalized_clients) {
printf('client\t%s\t%d\t%d\n',
client.address,
client.queries,
client.blocked);
}
print('commit\n');
exit(0);
+283
View File
@@ -0,0 +1,283 @@
#!/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_STATSD_FUNCTIONS_LIB="${SS_STATSD_FUNCTIONS_LIB:-/lib/functions.sh}"
SS_STATSD_CORE_LIB="${SS_STATSD_CORE_LIB:-/usr/lib/safeshield/core.sh}"
SS_STATSD_AWK_DIR="${SS_STATSD_AWK_DIR:-/usr/lib/safeshield/statistics}"
SS_STATSD_POLL_COMMAND="${SS_STATSD_POLL_COMMAND:-/usr/libexec/safeshield-stats-poll}"
SS_STATSD_IDENTITY_COMMAND="${SS_STATSD_IDENTITY_COMMAND:-/usr/libexec/safeshield-stats-identities}"
SS_STATSD_PERSISTENT_STATE_FILE="${SS_STATSD_PERSISTENT_STATE_FILE:-/etc/safeshield/statistics-state.tsv}"
SS_STATSD_PERSISTENT_JOURNAL_FILE="${SS_STATSD_PERSISTENT_JOURNAL_FILE:-/etc/safeshield/statistics-journal.tsv}"
SS_STATSD_SEQUENCE_FILE="${SS_STATSD_SEQUENCE_FILE:-/etc/safeshield/statistics-sequence.tsv}"
SS_STATSD_SEQUENCE_RESERVATION_SIZE="${SS_STATSD_SEQUENCE_RESERVATION_SIZE:-4096}"
SS_STATSD_UPLOAD_WINDOW_HOURS="${SS_STATSD_UPLOAD_WINDOW_HOURS:-2}"
SS_STATSD_PERSISTENT_INTERVAL_S="${SS_STATSD_PERSISTENT_INTERVAL_S:-3600}"
SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S="${SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S:-604800}"
# shellcheck disable=SC1090
. "$SS_STATSD_FUNCTIONS_LIB"
# shellcheck disable=SC1090
. "$SS_STATSD_CORE_LIB"
stats_poller_pid=''
stats_awk_pid=''
stats_fifo=''
stats_lock_dir=''
stats_lock_owned=0
stats_new_generation_id() {
local generation_id="${SS_STATSD_GENERATION_ID:-}"
if [ -n "$generation_id" ]; then
printf '%s\n' "$generation_id"
return 0
fi
if [ -r /proc/sys/kernel/random/uuid ]; then
IFS= read -r generation_id </proc/sys/kernel/random/uuid || generation_id=''
fi
if [ -z "$generation_id" ]; then
generation_id="fallback-$(date +%s 2>/dev/null || printf '0')-$$"
fi
printf '%s\n' "$generation_id"
}
stats_process_running() {
local pid="${1:-}"
[ -n "$pid" ] || return 1
kill -0 "$pid" 2>/dev/null
}
stats_cleanup() {
trap - EXIT HUP INT TERM
# Stop the low-frequency UBus poller first. awk then receives EOF and runs
# its END block, which performs the final snapshot and persistence flush.
if stats_process_running "$stats_poller_pid"; then
kill "$stats_poller_pid" 2>/dev/null || true
fi
[ -n "$stats_poller_pid" ] && wait "$stats_poller_pid" 2>/dev/null || true
stats_poller_pid=''
[ -n "$stats_awk_pid" ] && wait "$stats_awk_pid" 2>/dev/null || true
stats_awk_pid=''
[ -n "$stats_fifo" ] && rm -f "$stats_fifo"
if [ "$stats_lock_owned" -eq 1 ] && [ -n "$stats_lock_dir" ]; then
rm -rf "$stats_lock_dir"
fi
}
stats_terminate() {
local rc="${1:-0}"
stats_cleanup
exit "$rc"
}
stats_acquire_lock() {
local owner_pid=''
stats_lock_dir="${SS_STATISTICS_DIR}/collector.lock"
if mkdir "$stats_lock_dir" 2>/dev/null; then
stats_lock_owned=1
printf '%s\n' "$$" >"${stats_lock_dir}/pid"
return 0
fi
if [ -r "${stats_lock_dir}/pid" ]; then
owner_pid="$(cat "${stats_lock_dir}/pid" 2>/dev/null || true)"
fi
if stats_process_running "$owner_pid"; then
log_warn "Statistics collector is already running (pid=${owner_pid})"
return 1
fi
log_warn 'Removing stale statistics collector lock'
rm -rf "$stats_lock_dir" || return 1
mkdir "$stats_lock_dir" || return 1
stats_lock_owned=1
printf '%s\n' "$$" >"${stats_lock_dir}/pid"
return 0
}
stats_poll_loop() {
local snapshot_interval="$1"
local poll_command="$2"
local sleep_pid=''
trap 'if [ -n "$sleep_pid" ]; then kill "$sleep_pid" 2>/dev/null || true; fi; exit 0' HUP INT TERM
while :; do
if ! "$poll_command"; then
printf 'error\tdnsmasq_ubus_poll_failed\n'
fi
sleep "$snapshot_interval" &
sleep_pid=$!
wait "$sleep_pid" 2>/dev/null || true
sleep_pid=''
done
}
stats_run_collector() {
local snapshot_interval="$1"
local retention_hours="$2"
local lease_file="$3"
local persistent_state_file="$4"
local persistent_journal_file="$5"
local sequence_file="$6"
local generation_id ipv6_neigh_command='' ipv6_identity_command='' ipv6_identity_file="${SS_STATSD_IPV6_IDENTITY_FILE:-}" force_rebaseline=0 rc=0
if [ -f "${SS_STATISTICS_REBASELINE_FILE}" ]; then
force_rebaseline=1
fi
# This is only a candidate for a brand-new dataset. The collector modules keep
# the generation restored from tmpfs or persistent state when one already exists.
generation_id="$(stats_new_generation_id)"
if command_exists ip; then
ipv6_neigh_command='ip -6 neigh show 2>/dev/null'
fi
if [ -z "$ipv6_identity_file" ] && [ -x "$SS_STATSD_IDENTITY_COMMAND" ]; then
ipv6_identity_command="$SS_STATSD_IDENTITY_COMMAND 2>/dev/null"
fi
stats_fifo="${SS_STATISTICS_DIR}/events.$$"
rm -f "$stats_fifo"
mkfifo "$stats_fifo" || {
log_error "Failed to create statistics FIFO: ${stats_fifo}"
return 1
}
stats_poll_loop "$snapshot_interval" "$SS_STATSD_POLL_COMMAND" >"$stats_fifo" &
stats_poller_pid=$!
awk \
-v state_file="${SS_STATISTICS_STATE_FILE}" \
-v json_file="${SS_STATISTICS_JSON_FILE}" \
-v upload_json_file="${SS_STATISTICS_UPLOAD_JSON_FILE}" \
-v upload_window_hours="${SS_STATSD_UPLOAD_WINDOW_HOURS}" \
-v persistent_state_file="${persistent_state_file}" \
-v persistent_journal_file="${persistent_journal_file}" \
-v sequence_file="${sequence_file}" \
-v snapshot_seq_reservation_size="${SS_STATSD_SEQUENCE_RESERVATION_SIZE}" \
-v persistent_interval="${SS_STATSD_PERSISTENT_INTERVAL_S}" \
-v persistent_compact_interval="${SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S}" \
-v snapshot_interval="${snapshot_interval}" \
-v retention_hours="${retention_hours}" \
-v lease_file="${lease_file}" \
-v ipv6_neigh_command="${ipv6_neigh_command}" \
-v ipv6_identity_file="${ipv6_identity_file}" \
-v ipv6_identity_command="${ipv6_identity_command}" \
-v lease_refresh_interval=60 \
-v identity_cache_ttl=60 \
-v max_devices=128 \
-v generation_seed="${generation_id}" \
-v force_rebaseline="${force_rebaseline}" \
-v rebaseline_file="${SS_STATISTICS_REBASELINE_FILE}" \
-f "$SS_STATSD_AWK_DIR/00-common.awk" \
-f "$SS_STATSD_AWK_DIR/10-recovery.awk" \
-f "$SS_STATSD_AWK_DIR/20-identity.awk" \
-f "$SS_STATSD_AWK_DIR/30-aggregate.awk" \
-f "$SS_STATSD_AWK_DIR/40-persistence.awk" \
-f "$SS_STATSD_AWK_DIR/50-output.awk" \
-f "$SS_STATSD_AWK_DIR/90-main.awk" \
<"$stats_fifo" &
stats_awk_pid=$!
wait "$stats_awk_pid" || rc=$?
stats_awk_pid=''
if stats_process_running "$stats_poller_pid"; then
kill "$stats_poller_pid" 2>/dev/null || true
fi
wait "$stats_poller_pid" 2>/dev/null || true
stats_poller_pid=''
return "$rc"
}
main() {
local snapshot_interval configured_snapshot_interval retention_hours lease_file configured_lease_file
local persistent_state_file persistent_journal_file sequence_file persistence_mode rc=0
ss_load_config || return 1
[ "${ss_enabled}" = "1" ] || return 0
[ "${ss_statistics_enabled}" = "1" ] || return 0
if ! ss_dnsmasq_safeshield_block_supported; then
log_warn 'SafeShield statistics collector is disabled because dnsmasq does not provide the SafeShield extension'
return 0
fi
[ -x "$SS_STATSD_POLL_COMMAND" ] || {
log_error "SafeShield statistics poll command is unavailable: ${SS_STATSD_POLL_COMMAND}"
return 1
}
command_exists mkfifo || {
log_error 'mkfifo is required for SafeShield statistics'
return 1
}
persistent_state_file="${SS_STATSD_PERSISTENT_STATE_FILE}"
persistent_journal_file="${SS_STATSD_PERSISTENT_JOURNAL_FILE}"
sequence_file="${SS_STATSD_SEQUENCE_FILE}"
persistence_mode='journal'
if ! ss_statistics_persistence_enabled; then
persistent_state_file=''
persistent_journal_file=''
sequence_file=''
persistence_mode='volatile'
fi
mkdir -p "${SS_STATISTICS_DIR}" || return 1
if [ -n "$persistent_state_file" ]; then
mkdir -p "${persistent_state_file%/*}" || return 1
fi
if [ -n "$persistent_journal_file" ]; then
mkdir -p "${persistent_journal_file%/*}" || return 1
fi
if [ -n "$sequence_file" ]; then
mkdir -p "${sequence_file%/*}" || return 1
fi
stats_acquire_lock || return 0
trap 'stats_cleanup' EXIT
trap 'stats_terminate 129' HUP
trap 'stats_terminate 130' INT
trap 'stats_terminate 143' TERM
configured_snapshot_interval="${ss_statistics_snapshot_interval_s}"
snapshot_interval="$(ss_statistics_effective_snapshot_interval "$configured_snapshot_interval")"
retention_hours="${ss_statistics_retention_hours}"
lease_file="${SS_STATSD_DHCP_LEASE_FILE:-/tmp/dhcp.leases}"
if [ -z "${SS_STATSD_DHCP_LEASE_FILE:-}" ] && command_exists uci; then
configured_lease_file="$(uci -q get dhcp.@dnsmasq[0].leasefile 2>/dev/null || true)"
[ -n "$configured_lease_file" ] && lease_file="$configured_lease_file"
fi
log_info "Starting statistics collector (source=dnsmasq-ubus snapshot=${snapshot_interval}s configured_snapshot=${configured_snapshot_interval}s retention=${retention_hours}h persistence=${persistence_mode} journal=${SS_STATSD_PERSISTENT_INTERVAL_S}s compact=${SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S}s)"
stats_run_collector \
"$snapshot_interval" \
"$retention_hours" \
"$lease_file" \
"$persistent_state_file" \
"$persistent_journal_file" \
"$sequence_file" || rc=$?
return "$rc"
}
main "$@"
@@ -0,0 +1,28 @@
{
"safeshield": {
"description": "Access SafeShield service API",
"read": {
"ubus": {
"safeshield": [
"status",
"config",
"statistics",
"rules_list"
]
}
},
"write": {
"ubus": {
"safeshield": [
"config_update",
"set_enabled",
"refresh",
"rule_add",
"rule_delete",
"license_get",
"license_update"
]
}
}
}
}
@@ -0,0 +1,82 @@
'use strict';
// Keep private SafeShield RPC modules next to this rpcd entrypoint.
// Prepend the private module directory to the ucode require() search path.
unshift(REQUIRE_SEARCH_PATH, '/usr/share/rpcd/ucode/safeshield/*.uc');
let status = require('status');
let config = require('config');
let refresh = require('refresh');
let rules = require('rules');
let license = require('license');
let statistics = require('statistics');
return {
safeshield: {
status: {
call: status.build
},
config: {
call: config.build
},
statistics: {
call: statistics.build
},
config_update: {
args: {
values: {}
},
call: config.update
},
set_enabled: {
args: {
enabled: true
},
call: config.set_enabled
},
refresh: {
call: refresh.call
},
rules_list: {
args: {
action: ''
},
call: rules.list
},
rule_add: {
args: {
action: '',
domain: '',
refresh: true
},
call: rules.add
},
rule_delete: {
args: {
action: '',
domain: '',
refresh: true
},
call: rules.delete
},
license_get: {
call: license.get
},
license_update: {
args: {
license_key: ''
},
call: license.update
}
}
};
@@ -0,0 +1,276 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let uci = core.uci;
let PKG_NAME = core.PKG_NAME;
let CONFIG_SCHEMA_NAME = core.CONFIG_SCHEMA_NAME;
let CONFIG_SCHEMA_VERSION = core.CONFIG_SCHEMA_VERSION;
let CONFIG_SPEC = core.CONFIG_SPEC;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let to_bool = core.to_bool;
let to_int = core.to_int;
let mask_secret = core.mask_secret;
let api_error = core.api_error;
let uci_commit_option = core.uci_commit_option;
let bool_uci = core.bool_uci;
let run_service_action = runtime.run_service_action;
let start_refresh_async = runtime.start_refresh_async;
function config_public_value(name, spec) {
let raw = cfg(name, null);
if (spec.kind == 'bool') {
return to_bool(raw, spec.def);
}
return to_int(raw, spec.def);
}
function build_config() {
reload_uci();
let values = {};
for (let name, spec in CONFIG_SPEC) {
values[name] = config_public_value(name, spec);
}
values.enabled = to_bool(cfg('enabled', '0'), false);
return {
schema: {
name: CONFIG_SCHEMA_NAME,
version: CONFIG_SCHEMA_VERSION
},
values: values,
license: {
configured: !!cfg('license_key', ''),
key_masked: mask_secret(cfg('license_key', ''))
},
device: {
vendor: cfg('device_vendor', ''),
model: cfg('device_model', ''),
arch: cfg('device_arch', ''),
memory_mb: to_int(cfg('device_memory_mb', ''), 0)
}
};
}
function validate_config_value(name, value) {
let spec = CONFIG_SPEC[name];
if (!spec) {
if (name == 'enabled') {
return api_error('dedicated_method_required', 'Use set_enabled to change enabled state', name);
}
if (name == 'license_key') {
return api_error('dedicated_method_required', 'Use license_update to change the license key', name);
}
return api_error('unknown_option', 'Unsupported SafeShield configuration option', name);
}
if (spec.kind == 'bool') {
if (type(value) != 'bool') {
return api_error('invalid_type', 'Expected a boolean value', name);
}
return {
ok: true,
uci: bool_uci(value),
value: value
};
}
if (spec.kind == 'int') {
if (type(value) != 'int') {
return api_error('invalid_type', 'Expected an integer value', name);
}
if (value < spec.min || value > spec.max) {
return api_error(
'out_of_range',
sprintf('Value must be between %d and %d', spec.min, spec.max),
name
);
}
return {
ok: true,
uci: sprintf('%d', value),
value: value
};
}
return api_error('invalid_spec', 'Invalid internal configuration specification', name);
}
function config_update_call(request) {
reload_uci();
let values = request.args.values || {};
let changes = {};
let changed_names = [];
if (type(values) != 'object') {
return api_error('invalid_type', 'values must be an object', 'values');
}
for (let name, value in values) {
let checked = validate_config_value(name, value);
if (!checked.ok) {
return checked;
}
let current = cfg(name, null);
if (sprintf('%s', current) != checked.uci) {
changes[name] = checked.uci;
push(changed_names, name);
}
}
if (length(changed_names) == 0) {
return {
ok: true,
changed: [],
restarted: false,
config: build_config()
};
}
for (let name, value in changes) {
if (!uci.set(PKG_NAME, 'config', name, value)) {
uci.revert(PKG_NAME);
return api_error('uci_set_failed', 'Failed to update SafeShield configuration', name);
}
}
if (!uci.commit(PKG_NAME)) {
uci.revert(PKG_NAME);
return api_error('uci_commit_failed', 'Failed to commit SafeShield configuration');
}
reload_uci();
let statistics_only =
length(changed_names) == 1 &&
changed_names[0] == 'statistics_enabled';
if (statistics_only) {
let reconciled = run_service_action('reconcile_statistics', 60000);
if (!reconciled.ok) {
return {
ok: false,
committed: true,
changed: changed_names,
restarted: false,
reconciled: false,
service_rc: reconciled.rc,
error: {
code: 'statistics_reconcile_failed',
message: 'Configuration was committed but SafeShield statistics runtime reconciliation failed'
},
config: build_config()
};
}
return {
ok: true,
changed: changed_names,
restarted: false,
reconciled: true,
refresh: {
requested: false,
accepted: false,
reason: 'not_required'
},
config: build_config()
};
}
let restarted = run_service_action('restart', 60000);
if (!restarted.ok) {
return {
ok: false,
committed: true,
changed: changed_names,
restarted: false,
service_rc: restarted.rc,
error: {
code: 'service_restart_failed',
message: 'Configuration was committed but SafeShield failed to restart'
},
config: build_config()
};
}
let refresh = start_refresh_async();
return {
ok: true,
changed: changed_names,
restarted: true,
refresh: {
requested: true,
accepted: refresh.accepted,
reason: refresh.reason || ''
},
config: build_config()
};
}
function set_enabled_call(request) {
reload_uci();
if (request.args.enabled == null) {
return api_error('missing_argument', 'enabled is required', 'enabled');
}
if (type(request.args.enabled) != 'bool') {
return api_error('invalid_type', 'enabled must be a boolean', 'enabled');
}
let enabled = request.args.enabled;
let current = to_bool(cfg('enabled', '0'), false);
let changed = enabled != current;
if (changed && !uci_commit_option('enabled', bool_uci(enabled))) {
return api_error('uci_commit_failed', 'Failed to update enabled state');
}
let restarted = run_service_action('restart', 60000);
if (!restarted.ok) {
return {
ok: false,
committed: changed,
changed: changed,
accepted: false,
target_enabled: enabled,
reconciled: false,
service_rc: restarted.rc,
error: {
code: 'service_restart_failed',
message: 'Enabled state is configured but SafeShield failed to request runtime lifecycle reconciliation'
}
};
}
return {
ok: true,
changed: changed,
accepted: true,
target_enabled: enabled,
reconciled: false
};
}
return {
build: build_config,
update: config_update_call,
set_enabled: set_enabled_call
};
@@ -0,0 +1,250 @@
'use strict';
let fs = require('fs');
let uci = require('uci').cursor();
const PKG_NAME = 'safeshield';
const STATUS_FILE = `/dev/shm/${PKG_NAME}.status.json`;
const BLOCKLIST_FILE = '/tmp/dnsmasq.d/safeshield.blocklist';
const LOCAL_ALLOWLIST_FILE = '/etc/safeshield/allowlist';
const LOCAL_BLOCKLIST_FILE = '/etc/safeshield/blocklist';
const SCHEMA_NAME = 'safeshield.status';
const SCHEMA_VERSION = 1;
const CONFIG_SCHEMA_NAME = 'safeshield.config';
const CONFIG_SCHEMA_VERSION = 1;
const RULES_SCHEMA_NAME = 'safeshield.rules';
const RULES_SCHEMA_VERSION = 1;
const STATISTICS_SCHEMA_NAME = 'safeshield.statistics';
const STATISTICS_SCHEMA_VERSION = 3;
const STATISTICS_FILE = '/tmp/safeshield/statistics/statistics.json';
const SERVICE_INIT = '/etc/init.d/safeshield';
const RULES_DIR = '/etc/safeshield';
const CONFIG_SPEC = {
verbosity: { kind: 'int', def: 2, min: 0, max: 7 },
apply_local_overrides: { kind: 'bool', def: true },
max_blocklist_file_size_kb: { kind: 'int', def: 30000, min: 1, max: 2147483647 },
min_valid_line_count: { kind: 'int', def: 3000, min: 0, max: 2147483647 },
compress_blocklist: { kind: 'bool', def: false },
initial_dnsmasq_restart: { kind: 'bool', def: false },
dnsmasq_sanity_check: { kind: 'bool', def: true },
statistics_enabled: { kind: 'bool', def: true },
statistics_snapshot_interval_s: { kind: 'int', def: 60, min: 10, max: 3600 },
statistics_retention_hours: { kind: 'int', def: 168, min: 1, max: 168 },
download_timeout: { kind: 'int', def: 10, min: 1, max: 86400 },
download_retry: { kind: 'int', def: 3, min: 1, max: 100 },
pause_timeout: { kind: 'int', def: 20, min: 1, max: 86400 },
boot_start_delay_s: { kind: 'int', def: 30, min: 0, max: 86400 },
refresh_on_boot: { kind: 'bool', def: true },
refresh_interval_s: { kind: 'int', def: 28800, min: 1, max: 2147483647 },
require_wan: { kind: 'bool', def: true },
debug: { kind: 'bool', def: false }
};
function trim(s) {
return replace(s, /^[\r\n\t ]+|[\r\n\t ]+$/g, '');
}
function pkg_version() {
let s = fs.readfile(`/usr/lib/${PKG_NAME}/version`);
if (!s) {
return 'unknown';
}
return trim(s);
}
const PKG_VERSION = pkg_version();
function read_json_file(path, def) {
let s = fs.readfile(path);
if (!s) {
return def;
}
try {
return json(s);
}
catch (e) {
return def;
}
}
function file_exists(path) {
let st = fs.stat(path);
return !!st;
}
function file_size_kb(path) {
let st = fs.stat(path);
if (!st || type(st.size) != 'int') {
return 0;
}
return int((st.size + 1023) / 1024);
}
function to_bool(v, def) {
if (v == null) {
return def;
}
switch (sprintf('%s', v)) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
case '0':
case 'false':
case 'no':
case 'off':
return false;
}
return def;
}
function to_optional_bool(v) {
if (v == null || v == '') {
return null;
}
switch (sprintf('%s', v)) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
case '0':
case 'false':
case 'no':
case 'off':
return false;
}
return null;
}
function to_int(v, def) {
if (v == null || v == '') {
return def;
}
let n = int(v);
return (type(n) == 'int') ? n : def;
}
function reload_uci() {
return !!uci.load(PKG_NAME);
}
function cfg(name, def) {
let v = uci.get(PKG_NAME, 'config', name);
return (v == null) ? def : v;
}
function identity_cfg(name, def) {
let v = uci.get(PKG_NAME, 'identity', name);
return (v == null) ? def : v;
}
function mask_secret(v) {
let s = sprintf('%s', v || '');
let n = length(s);
if (n == 0) {
return '';
}
if (n <= 8) {
return '********';
}
return sprintf('%s...%s', substr(s, 0, 4), substr(s, n - 4));
}
function api_error(code, message, field) {
let error = {
code: code,
message: message
};
if (field) {
error.field = field;
}
return {
ok: false,
error: error
};
}
function uci_commit_option(name, value) {
if (!uci.set(PKG_NAME, 'config', name, sprintf('%s', value))) {
return false;
}
if (!uci.commit(PKG_NAME)) {
uci.revert(PKG_NAME);
return false;
}
reload_uci();
return true;
}
function uci_delete_option(name) {
if (!uci.delete(PKG_NAME, 'config', name)) {
return false;
}
if (!uci.commit(PKG_NAME)) {
uci.revert(PKG_NAME);
return false;
}
reload_uci();
return true;
}
function bool_uci(v) {
return v ? '1' : '0';
}
return {
uci: uci,
PKG_NAME: PKG_NAME,
PKG_VERSION: PKG_VERSION,
STATUS_FILE: STATUS_FILE,
BLOCKLIST_FILE: BLOCKLIST_FILE,
LOCAL_ALLOWLIST_FILE: LOCAL_ALLOWLIST_FILE,
LOCAL_BLOCKLIST_FILE: LOCAL_BLOCKLIST_FILE,
SCHEMA_NAME: SCHEMA_NAME,
SCHEMA_VERSION: SCHEMA_VERSION,
CONFIG_SCHEMA_NAME: CONFIG_SCHEMA_NAME,
CONFIG_SCHEMA_VERSION: CONFIG_SCHEMA_VERSION,
RULES_SCHEMA_NAME: RULES_SCHEMA_NAME,
RULES_SCHEMA_VERSION: RULES_SCHEMA_VERSION,
STATISTICS_SCHEMA_NAME: STATISTICS_SCHEMA_NAME,
STATISTICS_SCHEMA_VERSION: STATISTICS_SCHEMA_VERSION,
STATISTICS_FILE: STATISTICS_FILE,
SERVICE_INIT: SERVICE_INIT,
RULES_DIR: RULES_DIR,
CONFIG_SPEC: CONFIG_SPEC,
trim: trim,
read_json_file: read_json_file,
file_exists: file_exists,
file_size_kb: file_size_kb,
to_bool: to_bool,
to_optional_bool: to_optional_bool,
to_int: to_int,
reload_uci: reload_uci,
cfg: cfg,
identity_cfg: identity_cfg,
mask_secret: mask_secret,
api_error: api_error,
uci_commit_option: uci_commit_option,
uci_delete_option: uci_delete_option,
bool_uci: bool_uci
};
@@ -0,0 +1,90 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let trim = core.trim;
let mask_secret = core.mask_secret;
let api_error = core.api_error;
let uci_commit_option = core.uci_commit_option;
let uci_delete_option = core.uci_delete_option;
let start_refresh_async = runtime.start_refresh_async;
let reset_statistics_upload_state = runtime.reset_statistics_upload_state;
function license_get_call() {
reload_uci();
let key = cfg('license_key', '');
return {
ok: true,
license: {
configured: !!key,
key: key
}
};
}
function license_update_call(request) {
reload_uci();
if (request.args.license_key == null) {
return api_error('missing_argument', 'license_key is required; use an empty string to clear it', 'license_key');
}
let key = trim(request.args.license_key);
if (length(key) > 512 || match(key, /[\r\n]/)) {
return api_error('invalid_license_key', 'license_key must be at most 512 characters and contain no line breaks', 'license_key');
}
let current = cfg('license_key', '');
if (current == key) {
return {
ok: true,
changed: false,
license: {
configured: !!key,
key_masked: mask_secret(key)
},
refresh: {
requested: false,
accepted: false,
reason: 'unchanged'
}
};
}
let saved = key == ''
? uci_delete_option('license_key')
: uci_commit_option('license_key', key);
if (!saved) {
return api_error('uci_commit_failed', 'Failed to update the SafeShield license key');
}
reset_statistics_upload_state();
let refresh = start_refresh_async();
return {
ok: true,
changed: true,
license: {
configured: !!key,
key_masked: mask_secret(key)
},
refresh: {
requested: true,
accepted: refresh.accepted,
reason: refresh.reason || ''
}
};
}
return {
get: license_get_call,
update: license_update_call
};
@@ -0,0 +1,30 @@
'use strict';
let runtime = require('runtime');
let status = require('status');
let start_refresh_async = runtime.start_refresh_async;
let build_status = status.build;
function refresh_call() {
let r = start_refresh_async();
if (!r.accepted) {
return {
ok: r.reason == 'already_running',
accepted: false,
reason: r.reason,
status: build_status()
};
}
return {
ok: true,
accepted: true,
status: build_status()
};
}
return {
call: refresh_call
};
@@ -0,0 +1,316 @@
'use strict';
let fs = require('fs');
let core = require('core');
let runtime = require('runtime');
let LOCAL_ALLOWLIST_FILE = core.LOCAL_ALLOWLIST_FILE;
let LOCAL_BLOCKLIST_FILE = core.LOCAL_BLOCKLIST_FILE;
let RULES_SCHEMA_NAME = core.RULES_SCHEMA_NAME;
let RULES_SCHEMA_VERSION = core.RULES_SCHEMA_VERSION;
let RULES_DIR = core.RULES_DIR;
let trim = core.trim;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let to_bool = core.to_bool;
let api_error = core.api_error;
let start_local_apply_async = runtime.start_local_apply_async;
function normalize_rule_domain(value) {
if (type(value) != 'string') {
return '';
}
let domain = lc(trim(value));
if (!domain || substr(domain, 0, 1) == '#' || substr(domain, 0, 1) == '!') {
return '';
}
domain = replace(domain, /^0\.0\.0\.0[ \t]+/, '');
domain = replace(domain, /^127\.0\.0\.1[ \t]+/, '');
domain = replace(domain, /^local=\//, '');
domain = replace(domain, /^address=\//, '');
domain = replace(domain, /\/0\.0\.0\.0$/, '');
domain = replace(domain, /\/::$/, '');
domain = replace(domain, /\/$/, '');
domain = replace(domain, /^\//, '');
if (!match(domain, /^[a-z0-9._-]+$/) || length(domain) > 253 || index(domain, '..') >= 0) {
return '';
}
for (let label in split(domain, '.')) {
if (!label || length(label) > 63 || substr(label, 0, 1) == '-' || substr(label, -1) == '-') {
return '';
}
}
return domain;
}
function rule_path(action) {
if (action == 'allow') {
return LOCAL_ALLOWLIST_FILE;
}
if (action == 'block') {
return LOCAL_BLOCKLIST_FILE;
}
return null;
}
function read_rule_entries(path) {
let content = fs.readfile(path) || '';
let entries = [];
let seen = {};
for (let line in split(content, /\n/)) {
let domain = normalize_rule_domain(line);
if (!domain || seen[domain]) {
continue;
}
seen[domain] = true;
push(entries, domain);
}
sort(entries);
return entries;
}
function write_rule_file_atomic(path, content) {
if (!fs.stat(RULES_DIR) && !fs.mkdir(RULES_DIR)) {
return false;
}
let tmp = sprintf('%s.tmp.%d', path, time());
if (fs.writefile(tmp, content) == null) {
fs.unlink(tmp);
return false;
}
if (!fs.chmod(tmp, 0o644)) {
fs.unlink(tmp);
return false;
}
if (!fs.rename(tmp, path)) {
fs.unlink(tmp);
return false;
}
return true;
}
function rule_file_contains(path, domain) {
for (let existing in read_rule_entries(path)) {
if (existing == domain) {
return true;
}
}
return false;
}
function add_rule_to_file(path, domain) {
let content = fs.readfile(path) || '';
if (rule_file_contains(path, domain)) {
return {
ok: true,
changed: false
};
}
if (content && substr(content, -1) != '\n') {
content += '\n';
}
content += domain + '\n';
return {
ok: write_rule_file_atomic(path, content),
changed: true
};
}
function delete_rule_from_file(path, domain) {
let content = fs.readfile(path) || '';
let lines = split(content, /\n/);
let kept = [];
let changed = false;
for (let line in lines) {
if (normalize_rule_domain(line) == domain) {
changed = true;
continue;
}
push(kept, line);
}
if (!changed) {
return {
ok: true,
changed: false
};
}
let next = join('\n', kept);
return {
ok: write_rule_file_atomic(path, next),
changed: true
};
}
function build_rules(action) {
let allow = read_rule_entries(LOCAL_ALLOWLIST_FILE);
let block = read_rule_entries(LOCAL_BLOCKLIST_FILE);
if (action == 'allow') {
block = [];
}
else if (action == 'block') {
allow = [];
}
return {
schema: {
name: RULES_SCHEMA_NAME,
version: RULES_SCHEMA_VERSION
},
allow: allow,
block: block,
counts: {
allow: length(allow),
block: length(block)
}
};
}
function validate_rule_request(args) {
let action = args.action || '';
let domain = normalize_rule_domain(args.domain);
if (!rule_path(action)) {
return api_error('invalid_action', 'action must be either allow or block', 'action');
}
if (!domain) {
return api_error('invalid_domain', 'domain must be a plain valid domain name', 'domain');
}
return {
ok: true,
action: action,
domain: domain,
path: rule_path(action)
};
}
function apply_rule_refresh(args) {
reload_uci();
let should_refresh = (args.refresh == null) ? true : args.refresh;
if (!should_refresh) {
return {
requested: false,
accepted: false,
reason: 'not_requested'
};
}
if (!to_bool(cfg('apply_local_overrides', '1'), true)) {
return {
requested: false,
accepted: false,
reason: 'local_overrides_disabled'
};
}
let r = start_local_apply_async();
return {
requested: true,
accepted: r.accepted,
reason: r.reason || ''
};
}
function rules_list_call(request) {
let action = request.args.action || '';
if (action && !rule_path(action)) {
return api_error('invalid_action', 'action must be allow, block or omitted', 'action');
}
let result = build_rules(action);
result.ok = true;
return result;
}
function rule_add_call(request) {
let checked = validate_rule_request(request.args);
if (!checked.ok) {
return checked;
}
let result = add_rule_to_file(checked.path, checked.domain);
if (!result.ok) {
return api_error('rule_write_failed', 'Failed to update the local rule file');
}
let refresh = result.changed
? apply_rule_refresh(request.args)
: { requested: false, accepted: false, reason: 'unchanged' };
return {
ok: true,
action: checked.action,
domain: checked.domain,
added: result.changed,
refresh: refresh,
rules: build_rules(checked.action)
};
}
function rule_delete_call(request) {
let checked = validate_rule_request(request.args);
if (!checked.ok) {
return checked;
}
let result = delete_rule_from_file(checked.path, checked.domain);
if (!result.ok) {
return api_error('rule_write_failed', 'Failed to update the local rule file');
}
let refresh = result.changed
? apply_rule_refresh(request.args)
: { requested: false, accepted: false, reason: 'unchanged' };
return {
ok: true,
action: checked.action,
domain: checked.domain,
deleted: result.changed,
refresh: refresh,
rules: build_rules(checked.action)
};
}
return {
list: rules_list_call,
add: rule_add_call,
delete: rule_delete_call
};
@@ -0,0 +1,169 @@
'use strict';
let core = require('core');
let fs = require('fs');
let ubus = require('ubus').connect();
let PKG_NAME = core.PKG_NAME;
let STATUS_FILE = core.STATUS_FILE;
let SERVICE_INIT = core.SERVICE_INIT;
let read_json_file = core.read_json_file;
let to_bool = core.to_bool;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
function service_running(name) {
let r = ubus.call('service', 'list', { name: name });
if (!r || !r[name] || !r[name].instances) {
return false;
}
for (let inst_name, inst in r[name].instances) {
if (inst.running) {
return true;
}
}
return false;
}
function service_instance_running(name, instance_name) {
let r = ubus.call('service', 'list', { name: name });
if (!r || !r[name] || !r[name].instances || !r[name].instances[instance_name]) {
return false;
}
return !!r[name].instances[instance_name].running;
}
function dnsmasq_running() {
let r = ubus.call('service', 'list', { name: 'dnsmasq' });
if (!r || !r.dnsmasq || !r.dnsmasq.instances) {
return false;
}
for (let inst_name, inst in r.dnsmasq.instances) {
if (inst.running) {
return true;
}
}
return false;
}
function run_service_action(action, timeout_ms) {
let rc = system([ SERVICE_INIT, action ], timeout_ms || 60000);
return {
ok: rc == 0,
rc: rc
};
}
function refresh_running() {
let state = read_json_file(STATUS_FILE, {});
let data = state.data || {};
return data.status == 'running';
}
function reset_statistics_upload_state() {
fs.unlink('/tmp/safeshield/statistics/upload.credentials');
fs.unlink('/tmp/safeshield/statistics/upload.entitlement');
fs.unlink('/tmp/safeshield/statistics/upload.pending.json');
fs.unlink('/tmp/safeshield/statistics/upload.pending.meta');
fs.unlink('/tmp/safeshield/statistics/upload.state');
}
function start_refresh_async() {
reload_uci();
if (!to_bool(cfg('enabled', '0'), false)) {
return {
accepted: false,
reason: 'disabled'
};
}
if (!service_running(PKG_NAME)) {
return {
accepted: false,
reason: 'service_stopped'
};
}
if (refresh_running()) {
return {
accepted: false,
reason: 'already_running'
};
}
let rc = system([
'/bin/sh',
'-c',
'/etc/init.d/safeshield refresh_once </dev/null >/dev/null 2>&1 &'
], 5000);
return {
accepted: rc == 0,
reason: (rc == 0) ? '' : 'spawn_failed',
rc: rc
};
}
function start_local_apply_async() {
reload_uci();
if (!to_bool(cfg('enabled', '0'), false)) {
return {
accepted: false,
reason: 'disabled'
};
}
if (!to_bool(cfg('apply_local_overrides', '1'), true)) {
return {
accepted: false,
reason: 'local_overrides_disabled'
};
}
if (!service_running(PKG_NAME)) {
return {
accepted: false,
reason: 'service_stopped'
};
}
// The shell worker shares the refresh lock with full artifact refreshes.
// It waits for an in-flight refresh and then merges the newest local rule
// files against the retained api.block.txt cache. Duplicate workers are
// harmless because the engine fingerprints the normalized local state.
let rc = system([
'/bin/sh',
'-c',
'/etc/init.d/safeshield apply_local_rules </dev/null >/dev/null 2>&1 &'
], 5000);
return {
accepted: rc == 0,
reason: (rc == 0) ? '' : 'spawn_failed',
rc: rc
};
}
return {
service_running: service_running,
service_instance_running: service_instance_running,
dnsmasq_running: dnsmasq_running,
run_service_action: run_service_action,
refresh_running: refresh_running,
reset_statistics_upload_state: reset_statistics_upload_state,
start_refresh_async: start_refresh_async,
start_local_apply_async: start_local_apply_async
};
reload_service() {
restart
}
@@ -0,0 +1,142 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let PKG_NAME = core.PKG_NAME;
let STATISTICS_SCHEMA_NAME = core.STATISTICS_SCHEMA_NAME;
let STATISTICS_SCHEMA_VERSION = core.STATISTICS_SCHEMA_VERSION;
let STATISTICS_FILE = core.STATISTICS_FILE;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let read_json_file = core.read_json_file;
let to_bool = core.to_bool;
let to_int = core.to_int;
let service_instance_running = runtime.service_instance_running;
function sanitize_hourly(items) {
let result = [];
if (type(items) != 'array') {
return result;
}
for (let item in items) {
if (type(item) != 'object') {
continue;
}
push(result, {
bucket_start: to_int(item.bucket_start, 0),
queries: to_int(item.queries, 0),
blocked: to_int(item.blocked, 0)
});
}
return result;
}
function sanitize_devices(items) {
let result = [];
if (type(items) != 'array') {
return result;
}
for (let item in items) {
if (type(item) != 'object') {
continue;
}
let id = sprintf('%s', item.id || '');
if (!id) {
continue;
}
push(result, {
id: id,
mac: sprintf('%s', item.mac || ''),
ip: sprintf('%s', item.ip || ''),
hostname: sprintf('%s', item.hostname || ''),
identified: to_bool(item.identified, false),
queries: to_int(item.queries, 0),
blocked: to_int(item.blocked, 0),
hourly: sanitize_hourly(item.hourly)
});
}
return result;
}
function sanitize_source(item) {
if (type(item) != 'object') {
item = {};
}
return {
backend: sprintf('%s', item.backend || 'dnsmasq_ubus'),
available: to_bool(item.available, false),
instance_id: sprintf('%s', item.instance_id || ''),
transport_scope: sprintf('%s', item.transport_scope || ''),
client_capacity: to_int(item.client_capacity, 0),
tracked_clients: to_int(item.tracked_clients, 0),
untracked_queries: to_int(item.untracked_queries, 0),
untracked_blocked: to_int(item.untracked_blocked, 0),
poll_error_count: to_int(item.poll_error_count, 0),
last_error_at: to_int(item.last_error_at, 0)
};
}
function build_statistics() {
reload_uci();
let enabled = to_bool(cfg('statistics_enabled', '1'), true);
let retention_hours = to_int(cfg('statistics_retention_hours', '168'), 168);
let snapshot_interval_s = to_int(cfg('statistics_snapshot_interval_s', '60'), 60);
let data = read_json_file(STATISTICS_FILE, {});
let totals = (type(data.totals) == 'object') ? data.totals : {};
let collector_running = service_instance_running(PKG_NAME, 'statistics');
let effective_enabled = enabled && collector_running;
let source = sanitize_source(data.source);
if (!effective_enabled) {
source.available = false;
}
return {
schema: {
name: STATISTICS_SCHEMA_NAME,
version: STATISTICS_SCHEMA_VERSION
},
enabled: enabled,
effective_enabled: effective_enabled,
available: !!data.schema,
collector_running: collector_running,
volatile: to_bool(data.volatile, true),
storage: sprintf('%s', data.storage || 'tmpfs'),
persistent: to_bool(data.persistent, false),
persistence_enabled: to_bool(data.persistence_enabled, false),
persistence_healthy: to_bool(data.persistence_healthy, false),
persistence_mode: sprintf('%s', data.persistence_mode || 'none'),
persistent_error_count: to_int(data.persistent_error_count, 0),
persistent_last_error_at: to_int(data.persistent_last_error_at, 0),
persistent_updated_at: to_int(data.persistent_updated_at, 0),
persistent_checkpoint_interval_s: to_int(data.persistent_checkpoint_interval_s, 3600),
persistent_compacted_at: to_int(data.persistent_compacted_at, 0),
persistent_compact_interval_s: to_int(data.persistent_compact_interval_s, 604800),
snapshot_interval_s: snapshot_interval_s,
effective_snapshot_interval_s: to_int(data.snapshot_interval_s, snapshot_interval_s),
retention_hours: retention_hours,
generation_id: sprintf('%s', data.generation_id || ''),
snapshot_seq: to_int(data.snapshot_seq, 0),
source: source,
started_at: to_int(data.started_at, 0),
session_started_at: to_int(data.session_started_at, 0),
updated_at: to_int(data.updated_at, 0),
totals: {
queries: to_int(totals.queries, 0),
blocked: to_int(totals.blocked, 0)
},
hourly: sanitize_hourly(data.hourly),
device_limit: to_int(data.device_limit, 128),
devices_truncated: to_bool(data.devices_truncated, false),
devices: sanitize_devices(data.devices)
};
}
return {
build: build_statistics
};
@@ -0,0 +1,364 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let PKG_NAME = core.PKG_NAME;
let PKG_VERSION = core.PKG_VERSION;
let STATUS_FILE = core.STATUS_FILE;
let BLOCKLIST_FILE = core.BLOCKLIST_FILE;
let LOCAL_ALLOWLIST_FILE = core.LOCAL_ALLOWLIST_FILE;
let LOCAL_BLOCKLIST_FILE = core.LOCAL_BLOCKLIST_FILE;
let SCHEMA_NAME = core.SCHEMA_NAME;
let SCHEMA_VERSION = core.SCHEMA_VERSION;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let identity_cfg = core.identity_cfg;
let read_json_file = core.read_json_file;
let file_exists = core.file_exists;
let file_size_kb = core.file_size_kb;
let to_bool = core.to_bool;
let to_optional_bool = core.to_optional_bool;
let to_int = core.to_int;
let mask_secret = core.mask_secret;
let service_running = runtime.service_running;
let dnsmasq_running = runtime.dnsmasq_running;
function build_api_source(data, enabled) {
let api_resolve_ok = to_optional_bool(data.health_api_resolve);
let artifact_download_ok = to_optional_bool(data.health_artifact_download);
let last_result = data.last_result || '';
let last_error_code = data.last_error_code || '';
if (!enabled) {
last_result = 'disabled';
}
else if (api_resolve_ok == true && artifact_download_ok == true) {
last_result = 'ok';
}
else if (api_resolve_ok == false) {
last_result = last_error_code == 'safeshield_upgrade_required' ? last_error_code : 'api_resolve_failed';
}
else if (artifact_download_ok == false) {
last_result = last_error_code == 'safeshield_upgrade_required' ? last_error_code : 'artifact_download_failed';
}
else if (!last_result || last_result == 'idle') {
last_result = 'configured';
}
return {
section: 'hub_api',
name: 'SmartSafeHub API Artifacts',
action: 'block',
enabled: enabled,
last_result: last_result,
line_count: to_int(data.artifact_unique_domains || data.valid_line_count || 0, 0),
size_kb: to_int(data.blocklist_file_size_kb || 0, 0),
artifact_tier: data.artifact_tier || '',
artifact_version: data.artifact_version || '',
source_count: to_int(data.artifact_source_count || (to_bool(data.artifact_download_url_present || '0', false) ? 1 : 0), 0),
block_source_count: to_int(data.artifact_block_source_count || (to_bool(data.artifact_download_url_present || '0', false) ? 1 : 0), 0),
allow_source_count: to_int(data.artifact_allow_source_count || 0, 0)
};
}
function first_error_code(errors) {
if (type(errors) != 'array' || length(errors) == 0) {
return '';
}
let e = errors[0];
if (type(e) == 'object' && e.code != null) {
return sprintf('%s', e.code);
}
return sprintf('%s', e);
}
function build_summary(status, stage, valid_line_count, artifact_tier, artifact_version, errors, warnings) {
let sev = 'info';
let label = 'Idle';
let msg = 'SafeShield is idle';
if (status == 'ready') {
label = 'Ready';
if (artifact_tier || artifact_version) {
msg = sprintf('Hub artifact %s/%s is active, %d rules active', artifact_tier || 'unknown', artifact_version || 'unknown', valid_line_count);
}
else {
msg = sprintf('Hub artifact is active, %d rules active', valid_line_count);
}
}
else if (status == 'running') {
sev = 'notice';
label = 'Running';
msg = stage ? sprintf('Refresh in progress (%s)', stage) : 'Refresh in progress';
}
else if (status == 'error') {
sev = 'error';
label = 'Error';
msg = first_error_code(errors) || 'Refresh failed';
}
else if (status == 'paused') {
sev = 'warning';
label = 'Paused';
msg = 'SafeShield is paused';
}
else if (status == 'disabled') {
sev = 'warning';
label = 'Disabled';
msg = 'SafeShield is disabled';
}
if (type(warnings) == 'array' && length(warnings) > 0 && sev == 'info') {
sev = 'warning';
}
return {
label: label,
message: msg,
severity: sev
};
}
function build_status() {
reload_uci();
let state = read_json_file(STATUS_FILE, {});
let data = state.data || {};
let enabled = to_bool(cfg('enabled', '0'), false);
let refresh_on_boot = to_bool(cfg('refresh_on_boot', '1'), true);
let require_wan = to_bool(cfg('require_wan', '1'), true);
let refresh_interval_s = to_int(cfg('refresh_interval_s', '28800'), 28800);
let boot_start_delay_s = to_int(cfg('boot_start_delay_s', '30'), 30);
let license_key = cfg('license_key', '');
let license_configured = !!license_key;
let apply_local_overrides = to_bool(cfg('apply_local_overrides', '1'), true);
let cfg_physical_fingerprint = identity_cfg('physical_fingerprint', '');
let cfg_fingerprint_version = identity_cfg('fingerprint_version', '1');
let cfg_identity_provider = identity_cfg('identity_provider', '');
let cfg_identity_source = identity_cfg('identity_source', '');
let cfg_identity_strength = identity_cfg('identity_strength', '');
let cfg_identity_profile = identity_cfg('identity_profile', '');
let cfg_device_code = identity_cfg('device_code', '');
let cfg_device_code_source = identity_cfg('device_code_source', 'unknown');
let cfg_installation_id = identity_cfg('installation_id', '');
let cfg_device_vendor = cfg('device_vendor', '');
let cfg_device_model = cfg('device_model', '');
let cfg_device_arch = cfg('device_arch', '');
let cfg_device_memory_mb = cfg('device_memory_mb', '');
let status = data.status || (enabled ? 'idle' : 'disabled');
let stage = data.stage || '';
let valid_line_count = to_int(data.valid_line_count || 0, 0);
let warnings = (type(state.warnings) == 'array') ? state.warnings : [];
let errors = (type(state.errors) == 'array') ? state.errors : [];
let last_attempt = to_int(data.last_attempt || 0, 0);
let last_success = to_int(data.last_success || 0, 0);
let last_failure = to_int(data.last_failure || 0, 0);
let last_local_apply = to_int(data.last_local_apply || 0, 0);
let last_local_apply_failure = to_int(data.last_local_apply_failure || 0, 0);
let next_refresh_at = to_int(data.next_refresh_at || 0, 0);
let last_result = data.last_result || '';
let last_error_code = data.last_error_code || '';
let blocklist_file_size_kb = to_int(data.blocklist_file_size_kb || 0, 0);
let blocklist_installed = to_bool(data.blocklist_installed || '0', false);
let blocklist_verification_ok = to_bool(data.blocklist_verification_ok || '0', false);
let blocklist_test_domain = data.blocklist_test_domain || '';
let blocklist_test_domain_sample_count = to_int(data.blocklist_test_domain_sample_count || 0, 0);
let blocklist_test_domain_success_count = to_int(data.blocklist_test_domain_success_count || 0, 0);
let blocklist_test_domains = data.blocklist_test_domains || '';
let blocklist_backup_available = to_bool(data.blocklist_backup_available || '0', false);
let artifact_tier = data.artifact_tier || '';
let artifact_version = data.artifact_version || '';
let artifact_sha256 = data.artifact_sha256 || '';
let artifact_unique_domains = to_int(data.artifact_unique_domains || 0, 0);
let artifact_rules = to_int(data.artifact_rules || 0, 0);
let artifact_download_url_present = to_bool(data.artifact_download_url_present || '0', false);
let artifact_source_count = to_int(data.artifact_source_count || (artifact_download_url_present ? 1 : 0), 0);
let artifact_block_source_count = to_int(data.artifact_block_source_count || (artifact_download_url_present ? 1 : 0), 0);
let artifact_allow_source_count = to_int(data.artifact_allow_source_count || 0, 0);
let license_plan = data.license_plan || '';
let license_status = data.license_status || '';
let physical_fingerprint = data.physical_fingerprint || cfg_physical_fingerprint || '';
let fingerprint_version = to_int(data.fingerprint_version || cfg_fingerprint_version || 1, 1);
let identity_provider = data.identity_provider || cfg_identity_provider || '';
let identity_source = data.identity_source || cfg_identity_source || '';
let identity_strength = data.identity_strength || cfg_identity_strength || '';
let identity_profile = data.identity_profile || cfg_identity_profile || '';
let device_code = data.device_code || cfg_device_code || '';
let device_code_source = data.device_code_source || cfg_device_code_source || 'unknown';
let installation_id = data.installation_id || cfg_installation_id || '';
let device_profile = data.device_profile || '';
let dnsmasq_version = data.dnsmasq_version || '';
let dnsmasq_min_version = data.dnsmasq_min_version || '2.93';
let health_dnsmasq_binary = to_bool(data.health_dnsmasq_binary || '0', false);
let health_dnsmasq_version = to_bool(data.health_dnsmasq_version || '0', false);
let health_dnsmasq_features = to_bool(data.health_dnsmasq_features || '0', false);
let health_dnsmasq_confdir = to_bool(data.health_dnsmasq_confdir || '0', false);
let health_dnsmasq_initial_restart = to_optional_bool(data.health_dnsmasq_initial_restart);
let health_dnsmasq_final_restart = to_bool(data.health_dnsmasq_final_restart || '0', false);
let health_dns_runtime = to_bool(data.health_dns_runtime || '0', false);
let health_blocklist_verify = to_bool(data.health_blocklist_verify || '0', false);
let health_min_valid_line_count = to_bool(data.health_min_valid_line_count || '0', false);
let health_max_file_size = to_bool(data.health_max_file_size || '0', false);
let health_api_resolve = to_optional_bool(data.health_api_resolve);
let health_artifact_download = to_optional_bool(data.health_artifact_download);
let health_artifact_sha256 = to_optional_bool(data.health_artifact_sha256);
let hub_source = build_api_source(data, enabled);
let installed = blocklist_installed || file_exists(BLOCKLIST_FILE);
let refreshd_running = service_running(PKG_NAME);
let dns_running = dnsmasq_running();
let generated_at = time();
let next_refresh_in_s = 0;
if (next_refresh_at > generated_at) {
next_refresh_in_s = next_refresh_at - generated_at;
}
return {
schema: {
name: SCHEMA_NAME,
version: SCHEMA_VERSION
},
name: PKG_NAME,
version: PKG_VERSION,
enabled: enabled,
active: refreshd_running,
status: status,
stage: stage,
summary: build_summary(status, stage, valid_line_count, artifact_tier, artifact_version, errors, warnings),
runtime: {
refreshd_running: refreshd_running,
dnsmasq_running: dns_running,
dnsmasq_version: dnsmasq_version,
dnsmasq_min_version: dnsmasq_min_version,
dns_runtime_ok: health_dns_runtime || dns_running,
config_loaded: true,
require_wan: require_wan,
refresh_on_boot: refresh_on_boot,
last_result: last_result,
last_error_code: last_error_code
},
license: {
configured: license_configured,
key_masked: mask_secret(license_key),
plan: license_plan,
status: license_status
},
device: {
physical_fingerprint: physical_fingerprint,
fingerprint_version: fingerprint_version,
identity_provider: identity_provider,
identity_source: identity_source,
identity_strength: identity_strength,
identity_profile: identity_profile,
device_code: device_code,
device_code_source: device_code_source,
installation_id: installation_id,
profile: device_profile,
configured: {
physical_fingerprint: cfg_physical_fingerprint,
vendor: cfg_device_vendor,
model: cfg_device_model,
arch: cfg_device_arch,
memory_mb: to_int(cfg_device_memory_mb, 0)
}
},
artifact: {
resolved: !!artifact_tier || !!artifact_version || artifact_download_url_present,
tier: artifact_tier,
version: artifact_version,
sha256: artifact_sha256,
unique_domains: artifact_unique_domains,
rules: artifact_rules,
download_url_present: artifact_download_url_present,
source_count: artifact_source_count,
block_source_count: artifact_block_source_count,
allow_source_count: artifact_allow_source_count
},
local_overrides: {
enabled: apply_local_overrides,
allowlist_path: LOCAL_ALLOWLIST_FILE,
blocklist_path: LOCAL_BLOCKLIST_FILE
},
blocklist: {
installed: installed,
path: BLOCKLIST_FILE,
valid_line_count: valid_line_count,
file_size_kb: blocklist_file_size_kb || file_size_kb(BLOCKLIST_FILE),
verification_ok: blocklist_verification_ok,
test_domain: blocklist_test_domain,
test_domain_sample_count: blocklist_test_domain_sample_count,
test_domain_success_count: blocklist_test_domain_success_count,
test_domains: blocklist_test_domains,
compressed: false,
previous_backup_available: blocklist_backup_available
},
sources: {
mode: 'hub_api',
total: 1,
enabled: hub_source.enabled ? 1 : 0,
items: [ hub_source ]
},
health: {
overall: (status == 'error') ? 'error' : ((type(warnings) == 'array' && length(warnings) > 0) ? 'warning' : 'ok'),
checks: {
api_resolve: health_api_resolve,
artifact_download: health_artifact_download,
artifact_sha256: health_artifact_sha256,
dnsmasq_binary: health_dnsmasq_binary,
dnsmasq_version: health_dnsmasq_version,
dnsmasq_features: health_dnsmasq_features,
dnsmasq_confdir: health_dnsmasq_confdir,
dnsmasq_initial_restart: health_dnsmasq_initial_restart,
dnsmasq_final_restart: health_dnsmasq_final_restart,
dns_runtime: health_dns_runtime,
blocklist_verify: health_blocklist_verify,
min_valid_line_count: health_min_valid_line_count,
max_file_size: health_max_file_size
}
},
warnings: warnings,
errors: errors,
timestamps: {
generated_at: generated_at,
last_attempt: last_attempt,
last_success: last_success,
last_failure: last_failure,
last_local_apply: last_local_apply,
last_local_apply_failure: last_local_apply_failure,
next_refresh_at: next_refresh_at,
refresh_interval_s: refresh_interval_s,
next_refresh_in_s: next_refresh_in_s,
boot_start_delay_s: boot_start_delay_s
}
};
}
return {
build: build_status
};