💋 Sync 2026-10-11 00:16:14
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-10-11 00:16:14 +08:00
1 parent c7bafc635d
commit c71da84035
264 files changed
+8169 -3944

No files matched your search

+78
View File
@@ -1,5 +1,83 @@
# Changelog
## [0.3.25-r1] - 2026-10-10
- Bump version for release.
## [0.3.24-r6] - 2026-10-09
### Fixed
- Distinguish a stopped DNS protection service from a temporarily unavailable status check without changing existing running flags.
### Improved
- Reuse successful procd service lookup results for up to one second to reduce repeated RPC work; retry failed lookups on the next request.
### Tests
- Cover unknown service states, retry after lookup failure, and short-lived lookup reuse.
## [0.3.24-r5] - 2026-10-08
### Fixed
- Prevent SafeShield status and statistics RPC requests from blocking rpcd on nested service lookups.
- Limit service status checks to two seconds and return a safe stopped state when procd cannot respond.
### Tests
- Cover isolated, time-bounded service lookups and timeout fallback behavior.
## [0.3.24-r4] - 2026-10-08
### Changed
- Rename the active Hub plan/status runtime fields from `license_*` to `entitlement_*` and expose them through `safeshield.status.entitlement`.
- Keep subscription entitlement separate from the retired local SafeShield license-key concept so downstream consumers no longer need license-shaped status fields.
### Tests
- Update ucode and ShellSpec contracts for the `entitlement` status schema and prevent the retired `license` status object from returning.
## [0.3.24-r3] - 2026-10-08
### Removed
- Remove the legacy SafeShield license-key RPC, UCI runtime handling, and masked/raw key exposure. Device/account authentication is owned by SmartSafeHub.
- Remove the retired `license.uc` rpcd module and its host-side ucode tests.
### Changed
- Drive Cloud statistics eligibility exclusively from authenticated `/api/v1/devices/sync` responses instead of the presence of a local license key.
- Recheck a denied statistics entitlement on the normal 12-hour cadence so account or subscription changes can recover without a legacy key transition.
- Remove preserved legacy `safeshield.config.license_key` values during package upgrade or first boot.
### Fixed
- Keep the init-script runtime version synchronized with package version `0.3.24-r3`.
### Tests
- Add regression contracts that reject legacy license-key RPC/config paths and verify statistics entitlement recovery without `ss_license_key`.
## [0.3.24-r2] - 2026-10-03
### Added
- Support authenticated Hub requests with the SmartSafeHub device credential managed by the device component.
### Changed
- Resolve protection data through `/api/v1/devices/sync` instead of the license-specific resolve endpoint.
- Stop sending a license key when resolving protection data; account ownership and subscription entitlement are resolved by the Hub.
- Wait for the SmartSafeHub device credential when device registration is still pending; keep the router, DNS service, and any previously applied protection data available while retrying later.
- Refresh statistics upload credentials through `/api/v1/devices/sync` instead of the legacy license resolve API.
### Tests
- Added contract coverage for device-credential authentication, generic device sync, and removal of direct legacy license resolve calls from SafeShield.
## [0.3.24-r1] - 2026-09-19
- Bump version for release.
+7 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=safeshield
PKG_VERSION:=0.3.24
PKG_RELEASE:=3
PKG_VERSION:=0.3.25
PKG_RELEASE:=4
PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com>
PKG_LICENSE:=GPL-3.0-or-later
@@ -73,6 +73,7 @@ define Package/safeshield/install
$(INSTALL_CONF) ./files/etc/config/safeshield $(1)/etc/config/safeshield
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./files/etc/uci-defaults/90_safeshield_identity $(1)/etc/uci-defaults/90_safeshield_identity
$(INSTALL_BIN) ./files/etc/uci-defaults/91_safeshield_remove_legacy_license $(1)/etc/uci-defaults/91_safeshield_remove_legacy_license
$(INSTALL_DIR) $(1)/usr/lib/safeshield
$(INSTALL_BIN) ./files/usr/lib/safeshield/core.sh $(1)/usr/lib/safeshield/core.sh
@@ -110,6 +111,10 @@ export IPKG_INSTROOT
PKG_NAME='safeshield'
export PKG_NAME
if [ -z "$${IPKG_INSTROOT}" ]; then
if [ -x /etc/uci-defaults/91_safeshield_remove_legacy_license ]; then
/etc/uci-defaults/91_safeshield_remove_legacy_license >/dev/null 2>&1 || true
rm -f /etc/uci-defaults/91_safeshield_remove_legacy_license
fi
mkdir -p /etc/safeshield
if [ -r /usr/lib/safeshield/identity.sh ]; then
(
+29 -50
View File
@@ -380,7 +380,6 @@ SafeShield keeps the rpcd registration entrypoint intentionally small and loads
├── config.uc
├── refresh.uc
├── rules.uc
├── license.uc
└── statistics.uc
```
@@ -406,8 +405,6 @@ safeshield.refresh
safeshield.rules_list
safeshield.rule_add
safeshield.rule_delete
safeshield.license_get
safeshield.license_update
```
Read the current public configuration:
@@ -463,36 +460,30 @@ unconsumed values instead of reusing a sequence already observed by Hub. This
distinction lets cloud ingestion reject duplicate or out-of-order snapshots
without treating a process restart as a new dataset.
When statistics are enabled, a separate uploader can synchronize the aggregate
statistics with the SmartSafeHub Hub at `/api/v1/statistics`. Cloud upload is an
entitlement-controlled feature: the Hub currently grants it to eligible PRO and
ULTIMATE licenses in ACTIVE or TRIAL status and returns `statistics: null` for
free, unlicensed, expired, or revoked devices. SafeShield treats that Hub response as the
authoritative entitlement, disables network upload when it is absent, and keeps
collecting local statistics normally. A device with no configured license key is
blocked locally without making a statistics credential request.
When statistics are enabled, a separate uploader can synchronize aggregate
statistics with the SmartSafeHub Hub at `/api/v1/statistics`. Cloud upload is
controlled entirely by the Hub response from `/api/v1/devices/sync`. SafeShield
authenticates that sync with the SmartSafeHub device credential; it does not use
or require a local license key. If the Hub omits statistics credentials, network
upload is disabled while local statistics collection continues normally.
For an entitled device, the uploader gets a device-scoped bearer credential from
the existing `/api/v1/licenses/resolve` response and keeps that credential only
under `/tmp/safeshield/statistics/`; it is never written to flash or included in
status output. Routine uploads run every 30 minutes and contain a consistent
two-hour projection of the local hourly data. A full retained snapshot is sent
on initial synchronization, after a failed upload has recovered, and periodically
(approximately every 12 hours) to reconcile any missed window.
For an entitled device, the uploader stores the short-lived statistics bearer
credential only under `/tmp/safeshield/statistics/`; it is never written to flash
or included in status output. Routine uploads run every 30 minutes and contain a
consistent two-hour projection of the local hourly data. A full retained snapshot
is sent on initial synchronization, after a failed upload has recovered, and
periodically (approximately every 12 hours) to reconcile any missed window.
The uploader preserves the exact pending JSON across network retries. If the Hub
committed a request but the HTTP acknowledgement was lost, retrying the same
`generation_id` and `snapshot_seq` is therefore idempotent. An HTTP 401 clears
the cached credential and resolves the license again. If the refreshed response
no longer grants statistics upload, SafeShield discards the cloud pending payload
and stops statistics POST requests. While a license key remains configured, a
denied entitlement is rechecked at most once every 12 hours so a server-side
reactivation can recover without waiting for the normal artifact refresh cycle.
Changing or clearing the key through `license_update` immediately clears cached
entitlement, credentials, pending payloads, and upload progress; the next granted
entitlement therefore resumes with a full reconciliation. Local collection is
independent of upload entitlement and upload success, so a free license, WAN
outage, or Hub outage never interrupts on-router statistics.
the cached statistics credential and performs device sync once before retrying.
If the refreshed response no longer grants statistics upload, SafeShield discards
the cloud pending payload and stops statistics POST requests. A denied entitlement
is rechecked at most once every 12 hours, independent of plan or prior license-key
state, so account/subscription changes can recover automatically. Local collection
remains independent of upload entitlement and upload success, so WAN or Hub
outages never interrupt on-router statistics.
The collector implementation is split into focused AWK modules under
`/usr/lib/safeshield/statistics/` for common helpers, recovery, client identity,
@@ -543,8 +534,9 @@ without installing an unsupported directive. The statistics source also exposes
dnsmasq's `instance_id`, `transport_scope`, client-table capacity,
tracked-client count, `untracked_queries`, and `untracked_blocked`. The OpenWrt 25.12 dnsmasq integration reports `transport_scope=udp+tcp`, so both UDP and TCP DNS requests are included in the cumulative SafeShield counters.
Update writable configuration values. `enabled` and `license_key` are
intentionally excluded and have dedicated methods:
Update writable configuration values. `enabled` is intentionally excluded and
has a dedicated method. Device/account authentication is managed by SmartSafeHub
and is not writable through the SafeShield RPC API:
```sh
ubus call safeshield config_update '{
@@ -621,28 +613,15 @@ ubus call safeshield rule_delete '{
`rule_add` and `rule_delete` keep the public SafeShield API unchanged, but their automatic apply path no longer performs a full Hub artifact refresh. SafeShield retains the normalized Hub domains in `/tmp/safeshield/api.block.txt`, rebuilds only the local allow/block inputs, merges them atomically, restarts dnsmasq, and verifies runtime DNS. The local apply worker shares the normal refresh lock, so a rule edit made during a full refresh waits for that refresh and then reapplies the newest local state. If the cached Hub artifact is missing, SafeShield falls back to one normal full refresh.
Read the raw license key only when an authenticated management client explicitly
requests it. Normal `status`, `config` and `license_update` responses continue to
return only masked license metadata.
```sh
ubus call safeshield license_get
```
Update or clear the license key. Passing an empty string removes the configured
UCI option and immediately requests a SafeShield refresh so the device is resolved
as unlicensed/free again. SafeShield intentionally treats an absent `license_key`
option as the canonical unlicensed state; all runtime readers fall back to an empty
key when the option is not present.
```sh
ubus call safeshield license_update '{"license_key":"YOUR-LICENSE-KEY"}'
ubus call safeshield license_update '{"license_key":""}'
```
SafeShield does not store or manage a user-entered license key. SmartSafeHub owns
the device credential and the Hub resolves the current account/subscription
entitlement during `/api/v1/devices/sync`. SafeShield status may expose the
resolved plan/status for presentation, but never exposes or accepts account
credentials.
The package also installs an rpcd ACL named `safeshield`. Read access covers
`status`, `config` and `rules_list`; sensitive license access and mutating
methods are declared as write access.
`status`, `config` and `rules_list`; configuration, refresh, and local-rule
mutations are declared as write access.
## Contributors
+4 -6
View File
@@ -16,7 +16,7 @@ IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
readonly PKG_NAME='safeshield'
readonly PKG_VERSION='0.3.24-r1'
readonly PKG_VERSION='0.3.25-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'
@@ -232,7 +232,7 @@ service_triggers() {
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_tier artifact_version entitlement_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
@@ -246,7 +246,6 @@ status_service() {
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)"
@@ -264,7 +263,7 @@ status_service() {
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)"
entitlement_plan="$(json get entitlement_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)"
@@ -278,7 +277,6 @@ status_service() {
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'
@@ -296,7 +294,7 @@ status_service() {
printf 'status: %s\n' "$status"
[ -n "$stage" ] && printf 'stage: %s\n' "$stage"
[ -n "$license_plan" ] && printf 'license_plan: %s\n' "$license_plan"
[ -n "$entitlement_plan" ] && printf 'entitlement_plan: %s\n' "$entitlement_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"
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
# SafeShield authentication is device-scoped. Remove the retired raw license
# key from preserved configurations so obsolete secrets do not remain on flash.
if uci -q get safeshield.config.license_key >/dev/null 2>&1; then
uci -q delete safeshield.config.license_key
uci -q commit safeshield
fi
exit 0
@@ -177,6 +177,17 @@ ss_read_installed_version() {
printf '%s' "$version"
}
ss_device_credential_path() {
printf '%s' "${SS_DEVICE_CREDENTIAL_FILE:-/etc/smartsafehub/device-credential.json}"
}
ss_device_credential_token() {
local path
path="$(ss_device_credential_path)"
[ -r "$path" ] || return 1
ss_json_get_file "$path" '@.token'
}
ss_write_resolve_payload() {
local out="$1"
local model vendor arch memory physical_fingerprint safeshield_version
@@ -202,7 +213,6 @@ ss_write_resolve_payload() {
cat >"$out" <<__SAFESHIELD_JSON__
{
"license_key": $(ss_json_value "$ss_license_key"),
"device": {
"physical_fingerprint": $(ss_json_value "$physical_fingerprint"),
"fingerprint_version": ${SS_FINGERPRINT_VERSION:-1},
@@ -494,22 +504,25 @@ ss_resolve_artifact_sources() {
}
ss_resolve_artifact() {
local url response payload retries ok statistics_entitlement_rc
local url response payload retries ok statistics_entitlement_rc device_credential
SS_RESOLVE_ERROR_CODE='api_resolve_failed'
url='https://www.smartsafehub.com/api/v1/licenses/resolve'
url='https://www.smartsafehub.com/api/v1/devices/sync'
payload="$SS_API_PAYLOAD"
response="$SS_API_RESPONSE"
ss_status_set artifact_download_url_present "0"
if [ -z "$ss_license_key" ]; then
log_info "license_key is empty; resolving SafeShield artifact as unlicensed/free device"
ss_status_set license_plan "free"
ss_status_set license_status "unlicensed"
fi
ss_write_resolve_payload "$payload" || return 1
device_credential="$(ss_device_credential_token 2>/dev/null || true)"
if [ -z "$device_credential" ]; then
log_warn "SmartSafeHub device credential is not available yet; retrying on the next refresh"
SS_RESOLVE_ERROR_CODE='device_registration_pending'
ss_status_set device_registration_status 'pending'
ss_status_set health_api_resolve "0"
return 1
fi
ss_status_set device_registration_status 'registered'
retries=1
ok=0
@@ -517,7 +530,7 @@ ss_resolve_artifact() {
ss_should_stop && return 130
log_info "Resolving SafeShield artifact via Hub API (try ${retries}/${ss_download_retry})"
if ss_http_post_json "$url" "$payload" "$response" && [ -s "$response" ]; then
if ss_http_post_json "$url" "$payload" "$response" "Device ${device_credential}" && [ -s "$response" ]; then
ok=1
break
fi
@@ -546,8 +559,8 @@ ss_resolve_artifact() {
ss_resolved_artifact_version="$(ss_json_get_file "$response" '@.artifact.version')"
ss_resolved_artifact_unique_domains="$(ss_json_get_file "$response" '@.artifact.unique_domains')"
ss_resolved_artifact_rules="$(ss_json_get_file "$response" '@.artifact.rules')"
ss_resolved_license_plan="$(ss_json_get_file "$response" '@.license.plan')"
ss_resolved_license_status="$(ss_json_get_file "$response" '@.license.status')"
ss_resolved_entitlement_plan="$(ss_json_get_file "$response" '@.entitlement.plan')"
ss_resolved_entitlement_status="$(ss_json_get_file "$response" '@.entitlement.status')"
ss_resolved_device_profile="$(ss_json_get_file "$response" '@.device.profile')"
if command -v ss_statistics_sync_upload_entitlement >/dev/null 2>&1; then
@@ -555,7 +568,7 @@ ss_resolve_artifact() {
ss_statistics_sync_upload_entitlement "$response" || statistics_entitlement_rc=$?
case "$statistics_entitlement_rc" in
0) ;;
2) log_info "Cloud statistics upload is not enabled for the current license" ;;
2) log_info "Cloud statistics upload is not enabled for the current plan" ;;
*) log_warn "Hub API response did not include usable statistics upload credentials" ;;
esac
fi
@@ -572,12 +585,12 @@ ss_resolve_artifact() {
ss_status_set artifact_version "$ss_resolved_artifact_version"
ss_status_set artifact_unique_domains "${ss_resolved_artifact_unique_domains:-0}"
ss_status_set artifact_rules "${ss_resolved_artifact_rules:-0}"
ss_status_set license_plan "${ss_resolved_license_plan:-free}"
ss_status_set license_status "${ss_resolved_license_status:-unlicensed}"
ss_status_set entitlement_plan "${ss_resolved_entitlement_plan:-free}"
ss_status_set entitlement_status "${ss_resolved_entitlement_status:-unlicensed}"
ss_status_set device_profile "$ss_resolved_device_profile"
SS_RESOLVE_ERROR_CODE=''
log_ok "Resolved ${ss_resolved_artifact_tier:-unknown}/${ss_resolved_artifact_version:-unknown} artifact sources for plan ${ss_resolved_license_plan:-free}"
log_ok "Resolved ${ss_resolved_artifact_tier:-unknown}/${ss_resolved_artifact_version:-unknown} artifact sources for plan ${ss_resolved_entitlement_plan:-free}"
}
ss_verify_artifact_sha256() {
@@ -5,7 +5,6 @@
ss_enabled="0"
ss_verbosity="2"
ss_license_key=""
ss_device_vendor=""
ss_device_model=""
ss_device_arch=""
@@ -105,7 +104,6 @@ ss_load_config() {
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 '')"
@@ -358,20 +358,22 @@ ss_statistics_load_upload_credentials() {
}
ss_statistics_refresh_upload_credentials() {
local request response resolve_url retries rc=1
local request response sync_url retries rc=1 device_credential
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"
sync_url='https://www.smartsafehub.com/api/v1/devices/sync'
request="${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload-sync-request.json"
response="${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload-sync-response.json"
ss_load_config || return 1
device_credential="$(ss_device_credential_token 2>/dev/null || true)"
[ -n "$device_credential" ] || return 1
mkdir -p "${request%/*}" || return 1
ss_refresh_lock_open_wait "${ss_download_timeout:-10}" || return $?
if ! ss_write_resolve_payload "$request"; then
printf '{}\n' >"$request" || {
ss_refresh_lock_close
return 1
fi
}
retries=1
while [ "$retries" -le "${ss_download_retry:-3}" ]; do
@@ -379,7 +381,7 @@ ss_statistics_refresh_upload_credentials() {
rc=130
break
}
if ss_http_post_json "$resolve_url" "$request" "$response" && [ -s "$response" ]; then
if ss_http_post_json "$sync_url" "$request" "$response" "Device ${device_credential}" && [ -s "$response" ]; then
rc=0
ss_statistics_sync_upload_entitlement "$response" || rc=$?
break
@@ -70,8 +70,8 @@ ss_status_reset_blocklist_fields() {
}
ss_status_reset_artifact_fields() {
ss_status_set license_plan ""
ss_status_set license_status ""
ss_status_set entitlement_plan ""
ss_status_set entitlement_status ""
ss_status_set physical_fingerprint ""
ss_status_set fingerprint_version ""
ss_status_set identity_provider ""
@@ -8,23 +8,6 @@ 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 ;;
@@ -375,7 +375,7 @@ upload_send_pending() {
continue
fi
if [ "$refresh_rc" -eq 2 ]; then
log_info 'Cloud statistics upload is no longer enabled for the current license'
log_info 'Cloud statistics upload is no longer enabled for the current account entitlement'
upload_pending_clear
return 2
fi
@@ -433,14 +433,11 @@ main() {
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
if ss_statistics_upload_denied_recheck_due "$SS_STATS_UPLOAD_DENIED_RECHECK_S"; then
log_info 'Rechecking Hub statistics upload entitlement'
# Stamp this attempt before network I/O so a failed resolve cannot
# Stamp this attempt before network I/O so a failed device sync cannot
# turn into a routine upload retry loop while entitlement is denied.
ss_statistics_set_upload_entitlement denied >/dev/null 2>&1 || true
entitlement_rc=0
@@ -18,9 +18,7 @@
"set_enabled",
"refresh",
"rule_add",
"rule_delete",
"license_get",
"license_update"
"rule_delete"
]
}
}
@@ -8,7 +8,6 @@ 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 {
@@ -66,17 +65,7 @@ return {
refresh: true
},
call: rules.delete
},
license_get: {
call: license.get
},
license_update: {
args: {
license_key: ''
},
call: license.update
}
}
};
@@ -12,7 +12,6 @@ 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;
@@ -46,10 +45,6 @@ function build_config() {
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', ''),
@@ -67,9 +62,6 @@ function validate_config_value(name, value) {
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);
}
@@ -149,20 +149,6 @@ function identity_cfg(name, def) {
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 = {
@@ -242,7 +228,6 @@ return {
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,
@@ -1,90 +0,0 @@
'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
};
@@ -2,7 +2,6 @@
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;
@@ -12,43 +11,72 @@ 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;
// Do not call ubus synchronously from an rpcd handler. In particular,
// service.list may require the same event loop currently serving the caller.
// Use an independent ubus client with its built-in -t timeout (seconds).
// BusyBox/OpenWrt installations do not necessarily provide coreutils timeout.
// Only successful procd lookups are briefly reused. A failed lookup must not
// poison subsequent requests or be mistaken for a stopped service.
let service_cache = {};
function service_status(name, use_cache) {
if (name != PKG_NAME && name != 'dnsmasq') {
return { state: 'unknown', running: false, lookup_ok: false };
}
for (let inst_name, inst in r[name].instances) {
if (inst.running) {
return true;
let now = time();
let cached = service_cache[name];
if (use_cache && cached && cached.at == now) {
return cached.value;
}
let pipe = fs.popen(sprintf("ubus -t 2 call service list '{\"name\":\"%s\"}' 2>/dev/null", name), 'r');
if (!pipe) {
return { state: 'unknown', running: false, lookup_ok: false };
}
let output = pipe.read('all');
pipe.close();
if (!output) {
return { state: 'unknown', running: false, lookup_ok: false };
}
let result = json(output);
// procd returns an empty object when the named service is absent.
// That is a valid stopped response, not a transport failure.
if (type(result) != 'object') {
return { state: 'unknown', running: false, lookup_ok: false };
}
let instances = result[name] && result[name].instances;
let running = false;
if (type(instances) == 'object') {
for (let instance_name, instance in instances) {
if (instance && instance.running) {
running = true;
break;
}
}
}
return false;
let value = { state: running ? 'running' : 'stopped', running: running, lookup_ok: true, instances: instances || {} };
if (use_cache) {
service_cache[name] = { at: now, value: value };
}
return value;
}
function service_running(name) {
return service_status(name, false).running;
}
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;
let state = service_status(name, false);
return !!(state.instances && state.instances[instance_name] && state.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;
return service_running('dnsmasq');
}
function run_service_action(action, timeout_ms) {
@@ -85,10 +113,11 @@ function start_refresh_async() {
};
}
if (!service_running(PKG_NAME)) {
let service = service_status(PKG_NAME, false);
if (!service.running) {
return {
accepted: false,
reason: 'service_stopped'
reason: service.lookup_ok ? 'service_stopped' : 'service_unavailable'
};
}
@@ -129,10 +158,11 @@ function start_local_apply_async() {
};
}
if (!service_running(PKG_NAME)) {
let service = service_status(PKG_NAME, false);
if (!service.running) {
return {
accepted: false,
reason: 'service_stopped'
reason: service.lookup_ok ? 'service_stopped' : 'service_unavailable'
};
}
@@ -155,6 +185,7 @@ function start_local_apply_async() {
return {
service_running: service_running,
service_status: service_status,
service_instance_running: service_instance_running,
dnsmasq_running: dnsmasq_running,
run_service_action: run_service_action,
@@ -91,7 +91,8 @@ function build_statistics() {
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 collector_service = runtime.service_status(PKG_NAME, true);
let collector_running = !!(collector_service.instances && collector_service.instances.statistics && collector_service.instances.statistics.running);
let effective_enabled = enabled && collector_running;
let source = sanitize_source(data.source);
if (!effective_enabled) {
@@ -106,6 +107,8 @@ function build_statistics() {
effective_enabled: effective_enabled,
available: !!data.schema,
collector_running: collector_running,
collector_state: collector_service.lookup_ok ? (collector_running ? 'running' : 'stopped') : 'unknown',
collector_lookup_ok: collector_service.lookup_ok,
volatile: to_bool(data.volatile, true),
storage: sprintf('%s', data.storage || 'tmpfs'),
persistent: to_bool(data.persistent, false),
@@ -20,7 +20,6 @@ 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;
@@ -134,8 +133,6 @@ function build_status() {
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', '');
@@ -186,8 +183,8 @@ function build_status() {
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 entitlement_plan = data.entitlement_plan || '';
let entitlement_status = data.entitlement_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 || '';
@@ -217,8 +214,10 @@ function build_status() {
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 refresh_service = runtime.service_status(PKG_NAME, true);
let dns_service = runtime.service_status('dnsmasq', true);
let refreshd_running = refresh_service.running;
let dns_running = dns_service.running;
let generated_at = time();
let next_refresh_in_s = 0;
@@ -245,7 +244,11 @@ function build_status() {
runtime: {
refreshd_running: refreshd_running,
refreshd_state: refresh_service.state,
refreshd_lookup_ok: refresh_service.lookup_ok,
dnsmasq_running: dns_running,
dnsmasq_state: dns_service.state,
dnsmasq_lookup_ok: dns_service.lookup_ok,
dnsmasq_version: dnsmasq_version,
dnsmasq_min_version: dnsmasq_min_version,
dns_runtime_ok: health_dns_runtime || dns_running,
@@ -255,11 +258,9 @@ function build_status() {
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
entitlement: {
plan: entitlement_plan,
status: entitlement_status
},
device: {
+7
View File
@@ -50,6 +50,13 @@ Describe 'SafeShield core shell behavior'
The error should equal ''
End
It 'removes preserved legacy license keys during upgrade'
When call ss_case_legacy_license_cleanup
The status should be success
The output should equal ''
The error should equal ''
End
It 'keeps package and runtime versions synchronized'
When call ss_case_status_version
The status should be success
+17
View File
@@ -0,0 +1,17 @@
# shellcheck shell=sh
Describe 'SafeShield device API integration'
It 'uses the generic device sync endpoint with device authentication'
When call ss_case_device_api_contract
The status should be success
The output should equal ''
The error should equal ''
End
It 'waits cleanly when the SmartSafeHub device credential is unavailable'
When call ss_case_device_registration_pending
The status should be success
The output should equal ''
The error should equal ''
End
End
+2
View File
@@ -18,6 +18,8 @@ export SS_SPEC_ROOT
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/blocklist_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/device_api_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/runtime_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/statistics_cases.sh"
+6 -4
View File
@@ -150,7 +150,6 @@ ss_case_multi_artifact() (
SS_RESOLVED_SOURCES="$SS_TMP_DIR/resolved-sources.tsv"
SS_ARTIFACT_CACHE_STATE="$SS_TMP_DIR/artifact-sources.state"
SS_MAX_ARTIFACT_SOURCES=16
ss_license_key='test-license'
ss_download_retry=1
ss_download_timeout=10
ss_max_blocklist_file_size_kb=1024
@@ -159,11 +158,13 @@ ss_case_multi_artifact() (
ss_status_set() { :; }
ss_status_add_error() { :; }
log_info() { :; }
log_warn() { :; }
log_error() { :; }
log_ok() { :; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
ss_device_credential_token() { printf '%s\n' 'test-device-credential'; }
ss_write_resolve_payload() { printf '{}\n' >"$1"; }
ss_http_post_json() { printf '{}\n' >"$3"; }
ss_json_get_file() {
@@ -184,8 +185,8 @@ ss_case_multi_artifact() (
'@.artifact.tier') printf '%s\n' 'pro' ;;
'@.artifact.version') printf '%s\n' '20260830T120000Z' ;;
'@.artifact.unique_domains' | '@.artifact.rules') printf '%s\n' '4' ;;
'@.license.plan') printf '%s\n' 'pro' ;;
'@.license.status') printf '%s\n' 'active' ;;
'@.entitlement.plan') printf '%s\n' 'pro' ;;
'@.entitlement.status') printf '%s\n' 'active' ;;
'@.device.profile') printf '%s\n' 'test-profile' ;;
*) : ;;
esac
@@ -277,7 +278,6 @@ ss_case_upgrade_required() (
SS_API_RESPONSE="$SS_TMP_DIR/resolve-response.json"
SS_RESOLVED_SOURCES="$SS_TMP_DIR/resolved-sources.tsv"
SS_ARTIFACT_CACHE_STATE="$SS_TMP_DIR/artifact-sources.state"
ss_license_key='test-license'
ss_download_retry=3
ss_download_timeout=10
ss_max_blocklist_file_size_kb=1024
@@ -287,12 +287,14 @@ ss_case_upgrade_required() (
ss_status_add_error() { :; }
ss_status_add_warning() { :; }
log_info() { :; }
log_warn() { :; }
log_error() { :; }
log_ok() { :; }
command_exists() { return 1; }
sleep() { printf 'sleep\n' >>"$TMP_DIR/sleep"; }
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
ss_device_credential_token() { printf '%s\n' 'test-device-credential'; }
ss_write_resolve_payload() { printf '{}\n' >"$1"; }
printf '%s\n' 'HTTP error 426' >"$TMP_DIR/uclient.log"
+33 -8
View File
@@ -10,9 +10,6 @@ ss_case_utils() (
command_exists sh
! command_exists safeshield-command-that-does-not-exist
ss_spec_assert_eq "$(ss_mask_secret '')" ''
ss_spec_assert_eq "$(ss_mask_secret '12345678')" '********'
ss_spec_assert_eq "$(ss_mask_secret 'abcd1234wxyz')" 'abcd****wxyz'
is_valid_integer 0
is_valid_integer 42
for value in '' -1 1.5 12x; do
@@ -303,7 +300,6 @@ ss_case_resolve_payload() (
esac
}
ss_status_set() { :; }
ss_license_key='test-license'
SS_VERSION_FILE="$TMP_DIR/version"
export SS_VERSION_FILE
printf '%s\n' '0.3.18-r1' >"$SS_VERSION_FILE"
@@ -311,7 +307,8 @@ ss_case_resolve_payload() (
ss_write_resolve_payload "$payload"
ss_spec_assert_file_contains "$payload" ' "safeshield_version": "0.3.18-r1"'
! grep -Eq '^ "safeshield_version"' "$payload"
ss_spec_assert_file_contains "$payload" '"license_key": "test-license"'
! grep -Fq '"credential"' "$payload"
! grep -Fq '"license_key"' "$payload"
ss_spec_assert_file_contains "$payload" '"physical_fingerprint": "test-fingerprint"'
ss_spec_assert_file_contains "$payload" '"fingerprint_version": 1'
ss_spec_assert_file_contains "$payload" '"identity_provider": "factory_mac"'
@@ -348,15 +345,20 @@ ss_case_rpcd_contract() (
[ -f "$ENTRY" ]
[ -f "$ACL" ]
ss_spec_assert_file_contains "$ENTRY" "unshift(REQUIRE_SEARCH_PATH, '/usr/share/rpcd/ucode/safeshield/*.uc');"
for module in status config refresh rules license statistics; do
for module in status config refresh rules statistics; do
ss_spec_assert_file_contains "$ENTRY" "let $module = require('$module');"
done
for method in status config statistics config_update set_enabled refresh rules_list rule_add rule_delete license_get license_update; do
for method in status config statistics config_update set_enabled refresh rules_list rule_add rule_delete; do
ss_spec_assert_file_contains "$ENTRY" " $method: {"
ss_spec_assert_file_contains "$ACL" "\"$method\""
done
ss_spec_assert_file_contains "$MAKEFILE" './files/usr/share/rpcd/ucode/safeshield/*.uc'
ss_spec_assert_file_contains "$MAKEFILE" './files/usr/share/rpcd/acl.d/safeshield.json'
[ ! -e "$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield/license.uc" ]
ss_spec_assert_file_not_contains "$ENTRY" 'license_get'
ss_spec_assert_file_not_contains "$ENTRY" 'license_update'
ss_spec_assert_file_not_contains "$ACL" 'license_get'
ss_spec_assert_file_not_contains "$ACL" 'license_update'
ss_spec_assert_file_contains "$STATISTICS_MODULE" 'effective_snapshot_interval_s: to_int(data.snapshot_interval_s, snapshot_interval_s)'
)
@@ -413,7 +415,7 @@ ss_case_status_state() (
done
: >"$CALLS"
ss_status_reset_artifact_fields
for key in license_plan artifact_tier artifact_source_count artifact_allow_source_count; do
for key in entitlement_plan artifact_tier artifact_source_count artifact_allow_source_count; do
grep -F "$(printf 'set\t%s\t' "$key")" "$CALLS" >/dev/null
done
mkdir -p "$SS_DNSMASQ_DIR"
@@ -429,6 +431,29 @@ ss_case_status_state() (
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" 'address=/ads.example/#'
)
ss_case_legacy_license_cleanup() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
mkdir -p "$TMP_DIR/bin"
CALLS="$TMP_DIR/uci.calls"
export CALLS
cat >"$TMP_DIR/bin/uci" <<'EOF_UCI'
#!/bin/sh
printf '%s\n' "$*" >>"$CALLS"
case "$*" in
'-q get safeshield.config.license_key') exit 0 ;;
esac
exit 0
EOF_UCI
chmod 755 "$TMP_DIR/bin/uci"
PATH="$TMP_DIR/bin:$PATH" sh "$SS_SPEC_ROOT/files/etc/uci-defaults/91_safeshield_remove_legacy_license"
ss_spec_assert_file_contains "$CALLS" '-q get safeshield.config.license_key'
ss_spec_assert_file_contains "$CALLS" '-q delete safeshield.config.license_key'
ss_spec_assert_file_contains "$CALLS" '-q commit safeshield'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/Makefile" './files/etc/uci-defaults/91_safeshield_remove_legacy_license'
)
ss_case_status_version() (
set -eu
make_version="$(sed -n 's/^PKG_VERSION:=//p' "$SS_SPEC_ROOT/Makefile")"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# shellcheck shell=sh
ss_case_device_api_contract() (
set -eu
BLOCKLIST="$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
STATISTICS="$SS_SPEC_ROOT/files/usr/lib/safeshield/statistics.sh"
ss_spec_assert_file_not_contains "$BLOCKLIST" '/api/v1/devices/bootstrap'
ss_spec_assert_file_contains "$BLOCKLIST" '/api/v1/devices/sync'
ss_spec_assert_file_contains "$STATISTICS" '/api/v1/devices/sync'
ss_spec_assert_file_contains "$BLOCKLIST" 'Authorization: ${authorization}'
ss_spec_assert_file_contains "$BLOCKLIST" 'Device ${device_credential}'
ss_spec_assert_file_not_contains "$BLOCKLIST" "url='https://www.smartsafehub.com/api/v1/licenses/resolve'"
ss_spec_assert_file_not_contains "$STATISTICS" "resolve_url='https://www.smartsafehub.com/api/v1/licenses/resolve'"
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/config.sh" 'ss_license_key'
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/libexec/safeshield-statistics-uploader" 'ss_license_key'
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield.uc" 'license_update'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield/status.uc" 'entitlement: {'
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield/status.uc" 'license: {'
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh" 'license_plan'
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh" 'license_status'
)
ss_case_device_registration_pending() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
SS_TMP_DIR="$TMP_DIR/tmp"
SS_API_PAYLOAD="$SS_TMP_DIR/resolve-request.json"
SS_API_RESPONSE="$SS_TMP_DIR/resolve-response.json"
ss_download_retry=1
ss_download_timeout=10
mkdir -p "$SS_TMP_DIR"
ss_should_stop() { return 1; }
ss_status_set() { printf '%s=%s\n' "$1" "$2" >>"$TMP_DIR/status"; }
ss_status_add_error() { :; }
log_info() { :; }
log_warn() { :; }
log_error() { :; }
log_ok() { :; }
command_exists() { return 1; }
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
ss_write_resolve_payload() { printf '{}\n' >"$1"; }
ss_device_credential_token() { return 1; }
if ss_resolve_artifact; then
return 1
fi
ss_spec_assert_eq "$SS_RESOLVE_ERROR_CODE" 'device_registration_pending'
ss_spec_assert_file_contains "$TMP_DIR/status" 'device_registration_status=pending'
ss_spec_assert_file_contains "$TMP_DIR/status" 'health_api_resolve=0'
)
+7 -10
View File
@@ -529,7 +529,6 @@ EOF_CORE
MOCK_REFRESH_RESULT='allowed'
MOCK_DENIED_RECHECK_DUE='0'
SS_STATISTICS_UPLOAD_TOKEN=''
ss_license_key='paid-license-key'
ss_should_terminate=0
: >"$REFRESH_CALLS"
upload_sleep() {
@@ -625,14 +624,12 @@ EOF_CORE
upload_state_load
ss_spec_assert_eq "$upload_state_needs_full" '1'
# An unlicensed device is blocked locally without asking the Hub for a
# statistics credential. The long-lived uploader can remain idle so a later
# license update can re-enable it without restarting the collector.
MOCK_UPLOAD_ENTITLEMENT='allowed'
# Statistics eligibility comes only from device sync. A denied account/plan
# is rechecked on the normal entitlement cadence without any local license key.
MOCK_UPLOAD_ENTITLEMENT='denied'
MOCK_REFRESH_RESULT='allowed'
MOCK_DENIED_RECHECK_DUE='1'
SS_STATISTICS_UPLOAD_TOKEN='token-old'
ss_license_key=''
SS_STATISTICS_UPLOAD_TOKEN=''
ss_should_terminate=0
: >"$REFRESH_CALLS"
upload_sleep() {
@@ -640,9 +637,9 @@ EOF_CORE
return 1
}
main
ss_spec_assert_eq "$MOCK_UPLOAD_ENTITLEMENT" 'denied'
ss_spec_assert_eq "$(wc -l <"$REFRESH_CALLS" | tr -d ' ')" '0'
ss_spec_assert_eq "$SS_STATISTICS_UPLOAD_TOKEN" ''
ss_spec_assert_eq "$MOCK_UPLOAD_ENTITLEMENT" 'allowed'
ss_spec_assert_eq "$(wc -l <"$REFRESH_CALLS" | tr -d ' ')" '1'
ss_spec_assert_eq "$SS_STATISTICS_UPLOAD_TOKEN" 'token-new'
)
ss_case_statistics_upload_credentials() (
+3
View File
@@ -64,6 +64,9 @@ ss_case_dead_code_contract() (
! grep -Eq '^ss_detect_primary_mac\\(\\)[[:space:]]*\\{' "$BLOCKLIST"
! grep -F 'ss_debug=' "$CONFIG"
! grep -F '/lib/functions/network.sh' "$INIT"
! grep -F 'ss_license_key' "$CONFIG"
! grep -F 'license_key:' "$INIT"
! grep -Eq '^ss_mask_secret\(\)[[:space:]]*\{' "$UTILS"
# Keep the replacement/runtime contracts that made the retired helpers obsolete.
ss_spec_assert_file_contains "$BLOCKLIST" 'ss_identity_ensure "$model" "$arch"'
+1 -1
View File
@@ -34,7 +34,7 @@ ss_case_ucode() (
cp "$mock_dir"/*.uc "$test_modules/"
"$UCODE_BIN" -L "$test_modules" -D "TEST_TMP=\"$test_tmp\"" "$test_file" >/dev/null
}
for name in core config license refresh rules statistics status runtime; do
for name in core config refresh rules statistics status runtime; do
run_ucode_test "$name"
done
@@ -5,7 +5,6 @@ let values = {
debug: '0',
download_retry: '3',
statistics_enabled: '1',
license_key: 'abcd1234wxyz',
device_vendor: 'Junatum',
device_model: 'Test Router',
device_arch: 'aarch64',
@@ -33,12 +32,6 @@ function to_int(v, def) {
return type(n) == 'int' ? n : def;
}
function mask_secret(v) {
let s = sprintf('%s', v || '');
if (!length(s)) return '';
if (length(s) <= 8) return '********';
return sprintf('%s...%s', substr(s, 0, 4), substr(s, length(s) - 4));
}
function api_error(code, message, field) {
let error = { code: code, message: message };
@@ -71,7 +64,6 @@ return {
cfg: function(name, def) { return values[name] == null ? def : values[name]; },
to_bool: to_bool,
to_int: to_int,
mask_secret: mask_secret,
api_error: api_error,
uci_commit_option: uci_commit_option,
bool_uci: function(v) { return v ? '1' : '0'; }
@@ -1,43 +0,0 @@
'use strict';
let state = {
license_key: 'existing-license-key',
commits: 0,
deletes: 0
};
function trim(s) {
return replace(s, /^[\r\n\t ]+|[\r\n\t ]+$/g, '');
}
function mask_secret(v) {
let s = sprintf('%s', v || '');
if (!length(s)) return '';
if (length(s) <= 8) return '********';
return sprintf('%s...%s', substr(s, 0, 4), substr(s, length(s) - 4));
}
function api_error(code, message, field) {
let error = { code: code, message: message };
if (field) error.field = field;
return { ok: false, error: error };
}
return {
state: state,
reload_uci: function() { return true; },
cfg: function(name, def) { return name == 'license_key' ? state.license_key : def; },
trim: trim,
mask_secret: mask_secret,
api_error: api_error,
uci_commit_option: function(name, value) {
state.license_key = value;
state.commits++;
return true;
},
uci_delete_option: function(name) {
state.license_key = '';
state.deletes++;
return true;
}
};
@@ -1,14 +0,0 @@
'use strict';
let state = { refresh_count: 0, statistics_reset_count: 0, accepted: true, reason: '' };
return {
state: state,
reset_statistics_upload_state: function() {
state.statistics_reset_count++;
},
start_refresh_async: function() {
state.refresh_count++;
return { accepted: state.accepted, reason: state.reason };
}
};
@@ -0,0 +1,24 @@
'use strict';
let ubus = require('ubus');
let state = { calls: [], timeout: false };
function popen(command, mode) {
push(state.calls, command);
if (state.timeout) return null;
let name = match(command, /\"name\":\"([^\"]+)\"/);
let service_name = name ? name[1] : null;
let instance = service_name ? ubus.state.services[service_name] : null;
let response = {};
if (instance != null) response[service_name] = instance;
return {
read: function() { return sprintf('%J', response); },
close: function() { return true; }
};
}
return {
state: state,
popen: popen,
unlink: function() { return true; }
};
@@ -1,6 +1,8 @@
'use strict';
return {
service_status: function() {
return { state: 'running', running: true, lookup_ok: true, instances: { statistics: { running: true } } };
},
service_instance_running: function(name, instance) {
return name == 'safeshield' && instance == 'statistics';
}
+3 -11
View File
@@ -6,7 +6,6 @@ let config = {
require_wan: '1',
refresh_interval_s: '28800',
boot_start_delay_s: '30',
license_key: 'abcd1234wxyz',
apply_local_overrides: '1',
device_vendor: 'Junatum',
device_model: 'Test Router',
@@ -41,8 +40,8 @@ let state = {
health_dns_runtime: '1',
blocklist_installed: '1',
blocklist_file_size_kb: '2048',
license_plan: 'pro',
license_status: 'active'
entitlement_plan: 'pro',
entitlement_status: 'active'
},
warnings: [],
errors: []
@@ -67,12 +66,6 @@ function to_int(v, def) {
return type(n) == 'int' ? n : def;
}
function mask_secret(v) {
let s = sprintf('%s', v || '');
if (!length(s)) return '';
if (length(s) <= 8) return '********';
return sprintf('%s...%s', substr(s, 0, 4), substr(s, length(s) - 4));
}
return {
PKG_NAME: 'safeshield',
@@ -93,6 +86,5 @@ return {
file_size_kb: function() { return 0; },
to_bool: to_bool,
to_optional_bool: to_optional_bool,
to_int: to_int,
mask_secret: mask_secret
to_int: to_int
};
@@ -1,9 +1,11 @@
'use strict';
let state = { service_running: true, dnsmasq_running: true };
let state = { service_running: true, dnsmasq_running: true, lookup_ok: true };
return {
state: state,
service_status: function(name) {
let running = name == 'dnsmasq' ? state.dnsmasq_running : state.service_running;
return { running: running && state.lookup_ok, state: !state.lookup_ok ? 'unknown' : (running ? 'running' : 'stopped'), lookup_ok: state.lookup_ok };
},
service_running: function() { return state.service_running; },
dnsmasq_running: function() { return state.dnsmasq_running; }
};
+2 -4
View File
@@ -8,8 +8,6 @@ let built = config.build();
assert(built.schema.name == 'safeshield.config', 'config schema name is preserved');
assert(built.values.enabled == false, 'disabled state is normalized to boolean');
assert(built.values.download_retry == 3, 'integer options are normalized');
assert(built.license.configured == true, 'configured license is reported');
assert(built.license.key_masked == 'abcd...wxyz', 'license key is masked');
assert(built.device.memory_mb == 256, 'device memory is normalized to integer');
let invalid_values = config.update({ args: { values: 'not-an-object' } });
@@ -27,8 +25,8 @@ assert(unknown.ok == false && unknown.error.code == 'unknown_option', 'unknown o
let dedicated = config.update({ args: { values: { enabled: true } } });
assert(dedicated.ok == false && dedicated.error.code == 'dedicated_method_required', 'enabled requires dedicated method');
let dedicated_license = config.update({ args: { values: { license_key: 'new-key' } } });
assert(dedicated_license.ok == false && dedicated_license.error.code == 'dedicated_method_required', 'license_key requires dedicated method');
let retired_license_key = config.update({ args: { values: { license_key: 'new-key' } } });
assert(retired_license_key.ok == false && retired_license_key.error.code == 'unknown_option', 'retired license_key is not a writable option');
runtime.state.last_action = '';
let statistics = config.update({ args: { values: { statistics_enabled: false } } });
-3
View File
@@ -22,9 +22,6 @@ assert(core.to_int('42', 7) == 42, 'to_int converts numeric strings');
assert(core.to_int('', 7) == 7, 'to_int uses default for empty values');
assert(core.to_int(null, 7) == 7, 'to_int uses default for null');
assert(core.mask_secret('') == '', 'mask_secret keeps empty secrets empty');
assert(core.mask_secret('short') == '********', 'mask_secret fully masks short secrets');
assert(core.mask_secret('abcd1234wxyz') == 'abcd...wxyz', 'mask_secret partially masks long secrets');
let error = core.api_error('invalid_value', 'Invalid value', 'field_name');
assert(error.ok == false, 'api_error marks response as failed');
-44
View File
@@ -1,44 +0,0 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let license = require('license');
let current = license.get();
assert(current.ok == true && current.license.key == 'existing-license-key', 'license_get returns current key');
let missing = license.update({ args: {} });
assert(missing.ok == false && missing.error.code == 'missing_argument', 'license_update requires license_key');
let multiline = license.update({ args: { license_key: 'line1\nline2' } });
assert(multiline.ok == false && multiline.error.code == 'invalid_license_key', 'license_update rejects line breaks');
let oversized_key = '';
for (let i = 0; i < 513; i++) oversized_key += 'x';
let too_long = license.update({ args: { license_key: oversized_key } });
assert(too_long.ok == false && too_long.error.code == 'invalid_license_key', 'license_update rejects keys longer than 512 characters');
runtime.state.refresh_count = 0;
runtime.state.statistics_reset_count = 0;
let updated = license.update({ args: { license_key: ' new-license-key ' } });
assert(updated.ok == true && updated.changed == true, 'license_update accepts a new key');
assert(core.state.license_key == 'new-license-key', 'license_update trims key before saving');
assert(updated.license.key_masked == 'new-...-key', 'license_update masks saved key');
assert(runtime.state.refresh_count == 1, 'license_update requests refresh after change');
assert(runtime.state.statistics_reset_count == 1, 'license_update resets statistics upload entitlement after change');
runtime.state.refresh_count = 0;
runtime.state.statistics_reset_count = 0;
let unchanged = license.update({ args: { license_key: 'new-license-key' } });
assert(unchanged.ok == true && unchanged.changed == false, 'unchanged license does not write');
assert(runtime.state.refresh_count == 0, 'unchanged license does not refresh');
assert(runtime.state.statistics_reset_count == 0, 'unchanged license keeps statistics upload entitlement');
runtime.state.statistics_reset_count = 0;
let cleared = license.update({ args: { license_key: '' } });
assert(cleared.ok == true && cleared.changed == true, 'empty license clears configured key');
assert(core.state.license_key == '' && core.state.deletes == 1, 'empty license uses delete helper');
assert(cleared.license.configured == false, 'cleared license is reported unconfigured');
assert(runtime.state.statistics_reset_count == 1, 'clearing the license resets statistics upload entitlement');
print('ucode license tests: ok\n');
+14
View File
@@ -2,6 +2,7 @@
let core = require('core');
let ubus = require('ubus');
let fs = require('fs');
let runtime = require('runtime');
ubus.state.services.safeshield = {
@@ -10,6 +11,7 @@ ubus.state.services.safeshield = {
stopped: { running: false }
}
};
assert(runtime.service_status('safeshield', false).state == 'running', 'runtime distinguishes running services');
assert(runtime.service_running('safeshield') == true, 'service_running accepts any running instance');
assert(runtime.service_instance_running('safeshield', 'refreshd') == true, 'service_instance_running finds a running instance');
assert(runtime.service_instance_running('safeshield', 'stopped') == false, 'service_instance_running preserves stopped state');
@@ -20,6 +22,18 @@ ubus.state.services.dnsmasq = { instances: { main: { running: true } } };
assert(runtime.dnsmasq_running() == true, 'dnsmasq_running accepts a running dnsmasq instance');
ubus.state.services.dnsmasq.instances.main.running = false;
assert(runtime.dnsmasq_running() == false, 'dnsmasq_running rejects stopped dnsmasq instances');
assert(length(fs.state.calls) == 6, 'supported service checks use a separate bounded ubus client while unsupported names are rejected locally');
for (let call in fs.state.calls) {
assert(index(call, 'ubus -t 2 call service list') >= 0, 'service lookup uses ubus built-in timeout without an external timeout binary');
}
fs.state.timeout = true;
assert(runtime.service_running('safeshield') == false, 'failed or timed out service lookup fails closed');
assert(runtime.service_status('safeshield', true).state == 'unknown', 'lookup failure is exposed as unknown');
fs.state.timeout = false;
let calls_before = length(fs.state.calls);
assert(runtime.service_status('safeshield', true).lookup_ok == true, 'failed lookups are not cached');
assert(runtime.service_status('safeshield', true).lookup_ok == true, 'successful lookup can be reused briefly');
assert(length(fs.state.calls) == calls_before + 1, 'repeated successful lookups share the cached result');
let action = runtime.run_service_action('status', 1000);
assert(action.ok == true && action.rc == 0, 'run_service_action reports successful service commands');
@@ -5,6 +5,7 @@ let statistics = require('statistics');
let result = statistics.build();
assert(result.schema.name == 'safeshield.statistics' && result.schema.version == 3, 'statistics schema is preserved');
assert(result.enabled == true && result.effective_enabled == true && result.available == true, 'configured and effective statistics availability are reported');
assert(result.collector_state == 'running' && result.collector_lookup_ok == true, 'collector lookup detail is exposed');
assert(result.collector_running == true, 'statistics collector state is reported');
assert(result.snapshot_interval_s == 30 && result.effective_snapshot_interval_s == 30 && result.retention_hours == 24, 'statistics config values are normalized');
assert(result.generation_id == 'generation-test', 'statistics generation is preserved');
+8 -1
View File
@@ -1,9 +1,15 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let status = require('status');
let ready = status.build();
assert(ready.runtime.refreshd_state == 'running' && ready.runtime.dnsmasq_state == 'running' && ready.runtime.refreshd_lookup_ok == true, 'healthy status includes successful procd lookup details');
runtime.state.lookup_ok = false;
let unknown = status.build();
assert(unknown.runtime.refreshd_state == 'unknown' && unknown.runtime.refreshd_lookup_ok == false && unknown.active == false, 'unknown service lookups do not claim running');
runtime.state.lookup_ok = true;
assert(ready.status == 'ready', 'status preserves ready state');
assert(ready.summary.label == 'Ready' && ready.summary.severity == 'info', 'ready summary is generated');
assert(index(ready.summary.message, '42000 rules active') >= 0, 'ready summary includes active rule count');
@@ -11,7 +17,8 @@ assert(ready.artifact.source_count == 2, 'status reports artifact source count')
assert(ready.artifact.block_source_count == 1 && ready.artifact.allow_source_count == 1, 'status reports artifact action counts');
assert(ready.sources.items[0].last_result == 'ok', 'healthy API source is reported ok');
assert(ready.blocklist.installed == true && ready.blocklist.file_size_kb == 2048, 'blocklist state is normalized');
assert(ready.license.configured == true && ready.license.key_masked == 'abcd...wxyz', 'license state is masked');
assert(ready.entitlement.plan == 'pro' && ready.entitlement.status == 'active', 'Hub entitlement is exposed under the entitlement field');
assert(ready.license == null, 'status does not expose the retired license status field');
assert(ready.device.device_code == 'xiaomi-ax3000t', 'status exposes the canonical device code');
assert(ready.device.device_code_source == 'smartsafehub_firmware', 'status exposes the device code source');
assert(ready.health.overall == 'ok', 'healthy ready state reports overall ok');