mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 20:35:13 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -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 ''
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
+1170
File diff suppressed because it is too large.
Load diff
+128
@@ -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
|
||||
}
|
||||
Executable
+822
@@ -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
|
||||
}
|
||||
Executable
+153
@@ -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
@@ -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"
|
||||
}
|
||||
Executable
+63
@@ -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
@@ -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
|
||||
}
|
||||
Executable
+137
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
Reference in new issue
Block a user