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

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

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
root = true
[*]
charset = utf-8
trim_trailing_whitespace = true
end_of_line = lf
[*.sh]
indent_style = tab
indent_size = 4
[*/init.d/*]
indent_style = tab
indent_size = 4
[*/libexec/*]
indent_style = tab
indent_size = 4
[*.py]
indent_style = space
indent_size = 4
insert_final_newline = true
[Makefile]
indent_style = tab
indent_size = 4
insert_final_newline = true
+19
View File
@@ -0,0 +1,19 @@
# MacOS related
.DS_Store
.AppleDouble
.LSOverride
# Vim
*~
*.swp
*.swo
# IDE user config
.vscode/
.idea/
.vs/
# etc
*.orig
*.rej
/*.patch
+9
View File
@@ -0,0 +1,9 @@
repos:
- repo: local
hooks:
- id: safeshield-shell-lint
name: SafeShield shell lint
entry: sh scripts/lint.sh
language: system
pass_filenames: false
always_run: true
+2
View File
@@ -0,0 +1,2 @@
external-sources=true
severity=warning
+4
View File
@@ -0,0 +1,4 @@
--require spec_helper
--shell sh
--fail-no-examples
--format documentation
+506
View File
@@ -0,0 +1,506 @@
# Changelog
## [0.3.24-r1] - 2026-09-19
- Bump version for release.
## [0.3.23-r2] - 2026-09-19
### Added
- Send canonical `device.device_code` and `device.device_code_source` metadata with Hub resolve requests and expose the same identity through `safeshield.status`.
- Prefer `/usr/share/smartsafehub/firmware.json` as the authoritative device-code source for SmartSafeHub firmware, with exact board-name mappings for supported standalone SafeShield installations.
- Add regression coverage for firmware-provided device codes, board fallback, unknown devices, resolve payload metadata, and rpcd status exposure.
### Changed
- Keep unsupported or unrecognized hardware explicit by sending an empty `device_code` with source `unknown` instead of inferring a supported product from architecture, memory, or fuzzy model matching.
## [0.3.23-r1] - 2026-09-15
- Bump version for release.
## [0.3.22-r4] - 2026-09-13
### Changed
- Remove legacy shell helpers that no production path calls, including generic string transforms, the old status active/enabled helpers, unused failure/debug log wrappers, manual dnsmasq kill/clear helpers, and the pre-identity duplicate primary-MAC detector.
- Stop caching the unused shell-side `ss_debug` value while preserving the public UCI/rpcd `debug` option for compatibility.
- Remove the unused `/lib/functions/network.sh` import from the init script; interface reload triggers continue to use the procd API supplied by `rc.common`.
### Added
- Add a dead-code regression contract that prevents retired helpers and the unused network-functions import from being reintroduced while asserting their active replacement paths remain present.
## [0.3.22-r3] - 2026-09-13
### Added
- Add end-to-end shell state-machine coverage for full SafeShield refresh success, rollback failures, lock contention, and HTTP 426 upgrade-required preservation of the active blocklist.
- Add local-rule fast-apply coverage for cached-artifact fallback, duplicate fingerprints, lock timeout/termination, successful apply, and merge/restart/runtime/verification rollback paths.
- Add init lifecycle coverage for disabled startup, statistics runtime wiring, dnsmasq directive migration failures, active-blocklist restart ownership, and enabled/disabled shutdown states.
- Add artifact integrity coverage for retry recovery, partial checksum metadata, SHA-256 mismatch, missing `sha256sum`, and artifact size limits.
- Add refreshd scheduler coverage for failed-attempt retry anchoring, future/invalid timestamps, manual-refresh rescheduling, WAN waits, recent-success boot skips, and disabled runtime behavior.
- Add direct rpcd `runtime.uc` regression coverage and compile/behavior coverage for `status-store.uc`, including malformed state recovery and message mutations.
### Fixed
- Make refresh-lock regression coverage portable to macOS and other development hosts without a system `flock(1)` binary by using a test-only atomic lock shim while preserving the production `flock -n/-u` contract.
- Make the new lifecycle and refreshd regression helpers ShellCheck-clean by exporting mocks consumed by dynamically sourced production functions and narrowly annotating scheduler calls loaded from the generated test harness.
## [0.3.22-r2] - 2026-09-13
### Added
- Add regression coverage for authenticated OpenWrt `uclient-fetch` statistics POSTs, HTTP status propagation, and the no-header safety guard.
- Add statistics uploader state-machine coverage for corrupted pending metadata, stale/duplicate acknowledgements, periodic full reconciliation, and permanent HTTP 400 rejection.
- Expand artifact resolve payload assertions to cover device identity/registration metadata and invalid memory fallback.
- Expand rpcd ucode tests for statistics persistence/session metadata, config no-op behavior, dedicated options, runtime reconciliation failures, and oversized license keys.
## [0.3.22-r1] - 2026-09-13
- Bump version for release.
## [0.3.21-r5] - 2026-09-12
### Changed
- Reduce routine Hub statistics synchronization from every five minutes to every 30 minutes, cutting router TLS/JSON work and backend request volume by roughly six times while keeping the two-hour recovery window.
- Send a full retained statistics reconciliation approximately every 12 hours instead of every six hours; startup, generation changes, entitlement recovery, and upload-failure recovery still force the next eligible upload to be full.
- Include canonical client identity metadata in statistics payloads as `identities[]`, exposing DHCP client-id, DHCPv6 DUID, and MAC aliases already tracked by the local collector so the Hub can preserve one network client across private/randomized MAC changes.
## [0.3.21-r4] - 2026-09-12
### Added
- Track DHCP client identifiers from the dnsmasq lease file and DHCPv6 DUIDs from odhcpd so statistics history can survive private/randomized MAC changes.
- Add an odhcpd IPv6 identity helper that pairs DHCPv6 DUID/hostname metadata with kernel NDP MAC resolution.
- Add regression coverage for DHCP client-id MAC rotation, DHCPv6 DUID MAC rotation, conservative hostname fallback, and aggregated unresolved clients.
### Changed
- Reconcile a previously retained MAC identity into the currently observed MAC when a stable DHCP client-id or DHCPv6 DUID proves they are the same client.
- Use a conservative hostname fallback only when exactly one active MAC advertises a non-generic hostname and the retained candidate is no longer active.
- Serialize unresolved IP-only clients as a single `unknown` device while retaining their recent IP-specific buckets internally long enough for later DHCP/ARP/NDP reconciliation.
- Compact unresolved IP-only identities without a strong identifier into the local `unknown` bucket after six hours, bounding retained per-device state on networks with frequent IPv6 privacy-address rotation.
- Bump the internal statistics persistence schema from 5 to 6 to retain DHCP client-id and DHCPv6 DUID metadata across collector restarts and journal recovery.
## [0.3.21-r3] - 2026-09-12
### Changed
- Gate Hub statistics synchronization on the entitlement returned by `/api/v1/licenses/resolve`; eligible PRO and ULTIMATE licenses in ACTIVE or TRIAL status continue to upload while free and unlicensed devices keep statistics local only.
- Cache the non-secret cloud-upload entitlement in tmpfs so an explicitly ineligible device does not repeatedly request statistics credentials.
- Recheck an explicitly denied statistics entitlement at most once every 12 hours when a license key remains configured, allowing server-side license reactivation to recover without waiting for the normal artifact refresh cycle.
- Reset cached statistics entitlement, credentials, pending payloads, and upload progress immediately when `license_update` changes or clears the configured key so the next eligible synchronization starts with a full reconciliation.
### Fixed
- Stop statistics POST/re-authentication loops after the Hub returns `statistics: null`, including when a previously entitled license is rejected with HTTP 401.
- Clear stale statistics credentials, pending cloud payloads, and prior synchronization progress when upload entitlement is removed without affecting the local statistics collector or retained local data.
- Treat a backward wall-clock correction as immediately due for denied-entitlement revalidation so NTP adjustments cannot postpone cloud-upload recovery.
## [0.3.21-r2] - 2026-09-12
### Added
- Upload SafeShield DNS statistics to the Hub `/api/v1/statistics` endpoint with the device-scoped bearer credential issued by `/api/v1/licenses/resolve`.
- Add a separate statistics uploader process so Hub or WAN failures cannot block local DNS statistics collection.
- Add generation-scoped statistics schema v3 `snapshot_seq` ordering with durable sequence-range reservation on persistence-enabled devices.
- Add a compact two-hour upload projection for routine synchronization while retaining the full local 168-hour statistics dataset.
### Changed
- Upload statistics every five minutes, use a full retained snapshot at startup/recovery and approximately every six hours, and otherwise send only the recent two-hour window.
- Cache Hub statistics upload credentials only in tmpfs and refresh them through license resolve after an HTTP 401 response.
- Keep exact pending payloads across retry attempts so a lost HTTP acknowledgement can be safely resolved as a Hub `duplicate`.
- Keep upload credentials and pending synchronization state in tmpfs so bearer tokens and retry payloads are never written to flash.
### Fixed
- Force a full reconciliation after upload failures so data outside the routine two-hour window is recovered after connectivity returns.
- Reject a stale recent projection while the collector is atomically publishing a newer full snapshot, preventing a previous sequence from being promoted for upload.
- Recover statistics ordering by `snapshot_seq` within the same generation, including across backward NTP corrections, journal replay, collector restarts, and persistent reboot recovery.
## [0.3.21-r1] - 2026-09-09
- Bump version for release.
## [0.3.20-r4] - 2026-09-09
### Changed
- Keep `statistics_enabled` as the configured user preference when the dnsmasq SafeShield extension is unavailable; disable collection only for the current runtime so a later compatible firmware can restore it automatically.
- Expose `effective_enabled` separately from the configured Statistics toggle and report the source unavailable while the collector is not running.
- Propagate dnsmasq `untracked_blocked` metadata through collector state, JSON output, and the statistics RPC.
### Fixed
- Strictly validate dnsmasq `safeshield_stats` schema 1, required totals, client arrays, and non-negative cumulative counters before accepting a poll snapshot.
- Establish a fresh cumulative-counter baseline after Statistics or SafeShield has been disabled, preventing queries from the disabled period from being backfilled when collection is re-enabled.
## [0.3.20-r3] - 2026-09-09
### Changed
- Use the SafeShield namespace for dnsmasq integration: `safeshield-block=/domain/` and `dnsmasq.safeshield_stats`.
- Rename SafeShield-side dnsmasq capability helpers, migration paths, logs, documentation, and regression contracts to the SafeShield namespace.
### Fixed
- Keep DNS protection operational on an unpatched dnsmasq by falling back to standard `address=/domain/#` rules instead of installing an unsupported directive.
- Defensively disable and persist `statistics_enabled=0` when the SafeShield dnsmasq extension is unavailable, preventing statistics collector respawn/error loops.
- Reconcile installed `address=/domain/#` and `safeshield-block=/domain/` rules to the format supported by the running dnsmasq.
## [0.3.20-r2] - 2026-09-09
### Changed
- Replace per-query dnsmasq log ingestion with low-frequency polling of the SafeShield cumulative UBus statistics source.
- Emit SafeShield block rules with `safeshield-block=/domain/` so only SafeShield-owned rules increment the blocked counter.
- Preserve the existing statistics retention, per-device identity, journal persistence, and `generation_id` model while tracking dnsmasq `instance_id` counter epochs.
- Require dnsmasq 2.93 with the SafeShield block-accounting extension, matching the OpenWrt 25.12 baseline.
### Added
- Add `/usr/libexec/safeshield-stats-poll`, a one-shot ucode helper for `dnsmasq.safeshield_stats`.
- Expose statistics source availability, `instance_id`, transport scope, client capacity, tracked clients, untracked queries, and poll errors through the local statistics JSON/RPC.
- Persist the last dnsmasq cumulative counter baseline in collector state (and the persistent base snapshot where enabled) so collector restarts do not double-count an unchanged dnsmasq instance.
### Fixed
- Remove the high-frequency `logread -f`/AWK event path and managed `log-queries`/`log-async` configuration that could contribute to latency spikes on resource-constrained routers.
- Continue excluding loopback health-check traffic by subtracting loopback client counter deltas from global statistics.
- Migrate an already-installed legacy `address=/domain/#` SafeShield blocklist to the SafeShield directive during service startup.
## [0.3.20-r1] - 2026-09-08
- Bump version for release.
## [0.3.19-r4] - 2026-09-08
### Fixed
- Detect HTTP 426 responses from Hub artifact resolve and download requests and stop retrying immediately with `safeshield_upgrade_required`.
- Preserve the currently active blocklist without an unnecessary dnsmasq restore/restart when the Hub requires a newer SafeShield version.
- Keep the installed runtime version string in OpenWrt `0.3.19-rN` form so Hub minimum-version validation receives the same revision that the package reports.
## [0.3.19-r3] - 2026-09-07
### Added
- Resolve IPv6 DNS clients through the kernel NDP neighbor cache so privacy/temporary IPv6 addresses from the same link-layer client converge on one MAC-based statistics identity.
- Add regression coverage for IPv6 temporary-identity migration, shared-MAC neighbor merging, failed neighbor fallback, and the modular statistics runtime contract.
### Changed
- Split the monolithic statistics AWK collector into focused common, recovery, identity, aggregation, persistence, output, and lifecycle modules loaded together by `safeshield-statsd`.
- Keep unresolved IPv6 clients on the existing `ip:<address>` fallback until a valid neighbor `lladdr` becomes available, then migrate retained hourly buckets to the MAC identity.
## [0.3.19-r2] - 2026-09-06
### Added
- Add a persistent `generation_id` to statistics state, journal records, JSON output, and the public statistics RPC so one retained dataset can be identified across collector restarts.
- Document the distinction between dataset-scoped `started_at` and process-scoped `session_started_at`.
- Add regression coverage for stale journal recovery, internal loopback DNS queries, statistics generation/session identity, and temporary IPv6 device identities.
### Fixed
- Ignore committed journal transactions that are not newer than the loaded persistent base snapshot, preventing stale retained journal data from rolling statistics backward after interrupted compaction.
- Exclude loopback DNS traffic from both global and per-device statistics so SafeShield's own localhost health checks do not inflate query or blocked counters.
## [0.3.19-r1] - 2026-09-02
- Bump version for release.
## [0.3.18-r13] - 2026-09-02
### Changed
- Remove the `tests/run.sh` compatibility wrapper now that the shell regression suite is fully native ShellSpec.
- Run ShellSpec directly in GitHub Actions with `REQUIRE_UCODE=1 shellspec`, leaving a single test entry point for local and CI execution.
- Extend the tooling contract to require direct ShellSpec CI execution and prevent the legacy `tests/run.sh` wrapper from being reintroduced.
## [0.3.18-r12] - 2026-09-02
### Changed
- Replace the legacy ShellSpec adapter with native ShellSpec suites for the existing SafeShield shell regression coverage.
- Organize the converted suite into core, blocklist, runtime, statistics, ucode, and tooling specs so failures are reported by feature-oriented ShellSpec examples.
- Move reusable shell fixtures and integration scenarios under `spec/support/`, where they remain covered by the normal shfmt, ShellCheck, and `sh -n` lint pipeline.
- Keep `tests/run.sh` as the compatibility entry point while ShellSpec directly discovers only native `spec/*_spec.sh` files.
- Preserve the existing ucode test programs under `tests/ucode/` and invoke them from the native ShellSpec ucode example.
## [0.3.18-r11] - 2026-09-02
### Changed
- Adopt ShellSpec 0.28.1 as the shell test runner and use its `spec/*_spec.sh` discovery instead of maintaining a manual test list in `tests/run.sh`.
- Run the existing POSIX shell regression scripts as individual ShellSpec examples so their established assertions and OpenWrt-specific mocks remain intact during the migration.
- Pin ShellSpec 0.28.1 in GitHub Actions and keep the compatibility `tests/run.sh` entry point for local and CI usage.
- Exclude ShellSpec DSL spec files from general `shfmt` formatting while continuing to lint normal shell helpers and production scripts.
- Treat legacy test stdout as an expected success message and require empty stderr so ShellSpec does not emit unverified-output warnings.
## [0.3.18-r10] - 2026-09-02
### Fixed
- Clear stale Statistics persistence health, error and checkpoint metadata when persistent storage is disabled.
- Keep persistence health non-erroring in volatile mode and avoid scheduling persistent checkpoint or compaction work when no persistent paths are configured.
- Record the collector's effective snapshot interval in runtime statistics and expose it separately from the configured UCI interval so GL-MT300N-V2 reports its 300-second runtime interval.
## [0.3.18-r9] - 2026-09-02
### Changed
- Run Statistics on GL-MT300N-V2 in volatile tmpfs-only mode by disabling the persistent snapshot and journal paths while keeping runtime state and `statistics.json` snapshots available.
- Avoid creating or touching Statistics flash-persistence directories on GL-MT300N-V2; other device profiles continue using the existing journal-based persistence path unchanged.
### Fixed
- Prevent periodic Statistics persistence and journal I/O from contributing to latency spikes on resource-constrained GL-MT300N-V2 routers.
## [0.3.18-r8] - 2026-09-01
### Changed
- Merge already sorted normalized Hub artifact shards with `sort -m -u` instead of concatenating and fully sorting them again during blocklist refresh.
- Reuse the existing statistics totals pass to track each device's first and last retained hourly bucket, then serialize only that active range in `statistics.json`.
## [0.3.18-r7] - 2026-09-01
### Changed
- Filter the live statistics log stream in `logread` so only dnsmasq messages enter the FIFO and AWK collector.
- Replace refreshd's one-second polling sleep loop with a single interruptible sleep child that is cancelled immediately on TERM or INT.
- Stop blocklist test-domain sampling as soon as the requested limit is reached and skip redundant blocklist rule scans for domains already sampled from the installed blocklist.
## [0.3.18-r6] - 2026-09-01
### Changed
- Increase the dnsmasq asynchronous statistics log queue from 25 to 50 lines on GL-MT300N-V2 while keeping the existing 25-line queue on other device profiles.
- Use a 300-second hot statistics snapshot interval on GL-MT300N-V2 when the configured interval is the standard 60-second default, while preserving non-default configured intervals.
- Cache resolved DNS client IP-to-device identities for up to 60 seconds, bounded by the DHCP/ARP lease refresh interval.
- Skip redundant tmpfs state and JSON serialization when no statistics state changed, while still forcing pending persistent journal data to flush on graceful collector shutdown and serializing persistence health changes when a flush updates them.
### Fixed
- Reduce per-query AWK identity processing and periodic snapshot CPU bursts that can cause DNS latency on resource-constrained routers such as GL-MT300N-V2.
## [0.3.18-r5] - 2026-09-01
### Added
- Add journal-based persistence for SafeShield statistics to reduce flash write amplification on low-end devices.
- Persist completed hourly global and per-device statistics by appending compact journal transactions instead of rewriting the full retained statistics state every hour.
- Add transactional `begin`/`commit` markers so incomplete journal writes caused by power loss are ignored during recovery.
- Add periodic journal compaction that merges retained journal data into the persistent base snapshot.
- Preserve statistics journal data across sysupgrade.
- Expose journal persistence mode and compaction metadata through the statistics API.
### Changed
- Keep the 60-second statistics snapshot in tmpfs while limiting routine flash writes to hourly journal updates.
- Change full persistent state rewrites from hourly checkpoints to periodic compaction, with a default compaction interval of 7 days.
- Restore statistics at startup by loading the persistent base snapshot and replaying committed journal transactions.
- Persist IP-to-MAC device identity reconciliation through journal records without losing cumulative or hourly statistics.
- Use absolute hourly bucket updates in the journal so replay remains idempotent after restarts or interrupted compaction.
### Fixed
- Avoid the periodic full-state flash writes that can cause noticeable latency on resource-constrained devices such as the GL-MT300N-V2.
- Ignore incomplete journal transactions after an unexpected shutdown or power loss.
- Keep in-memory statistics operational when a journal persistence write fails, while reporting persistence health through the statistics API.
## [0.3.18-r4] - 2026-09-01
### Fixed
- Reconcile temporary `ip:<address>` statistics identities with MAC-based device identities once the client MAC address becomes available.
- Merge existing cumulative and hourly query/block counters into the resolved MAC identity without losing historical statistics.
- Remove stale IP-based device records and hourly buckets after a successful identity migration.
- Fall back to `/proc/net/arp` for client MAC resolution when DHCP lease data is unavailable.
- Make device hourly bucket migration safe across different AWK implementations by avoiding in-place associative array mutation during iteration.
## [0.3.18-r3] - 2026-09-01
### Fixed
- Preserve per-device hourly statistics through the public statistics RPC.
- Prefer the newest valid tmpfs or persistent statistics state when the collector restarts.
- Reject persistent statistics state whose stored totals do not match its retained hourly buckets.
- Expose persistent checkpoint health and back off retries after flash write failures.
## [0.3.18-r2] - 2026-09-01
### Added
- Persist retained global and per-device hourly SafeShield statistics across router reboots.
- Expose per-device hourly buckets through the statistics API for accurate local history and future cloud ingestion.
### Changed
- Keep 60-second hot snapshots in tmpfs while checkpointing aggregate statistics to flash at most once per hour and on graceful collector shutdown.
- Restore persistent statistics after reboot, retain up to 168 hourly buckets, and migrate version 1 per-device cumulative counters without dropping totals.
- Keep the persistent statistics checkpoint across sysupgrade.
## [0.3.18-r1] - 2026-09-01
### Added
- Include the installed SafeShield package version in Hub artifact resolve requests for operational diagnostics and compatibility analysis.
## [0.3.17-r1] - 2026-08-30
- Bump version for release.
## [0.3.16-r4] - 2026-08-30
### Fixed
- Fix the shared ucode `trim()` helper so leading and trailing whitespace are both removed in a single call.
- Keep ucode unit-test mocks aligned with the production trimming behavior and add explicit leading/trailing regression cases.
## [0.3.16-r3] - 2026-08-30
### Added
- Add host-side ucode unit tests for core helpers and rpcd config, license, refresh, rules, statistics, and status behavior.
- Compile-check all production rpcd ucode modules before running ucode unit tests.
- Run ucode tests in GitHub Actions using the ucode revision shipped by the OpenWrt 25.12 package feed.
## [0.3.16-r2] - 2026-08-30
### Added
- Add host-side regression tests for shared utility helpers, configuration validation, identity persistence/profile mapping, status state transitions, and the rpcd/ACL public contract.
### Changed
- Run the new regression tests from the existing `tests/run.sh` entrypoint.
## [0.3.16-r1] - 2026-08-30
### Added
- Support multiple Hub artifact sources through `artifact.sources[]` while preserving the legacy single `artifact.download_url` response.
- Support independent `block` and `allow` actions for remote artifact sources so separately distributed datasets can be combined only at router runtime.
- Download and SHA-256 verify each remote source independently and cache normalized source files for local-rule reapply operations.
- Expose resolved block/allow source counts through the SafeShield status API.
### Changed
- Keep downloaded Hub sources separate in tmpfs and merge them only when generating the active dnsmasq blocklist.
- Define override precedence as local allow > local block > Hub allow > Hub block when multiple source actions overlap.
## [0.3.15-r2] - 2026-08-30
### Changed
- Add an installed third-party data notice for HaGeZi-derived DNS blocklist artifacts.
- Install a GPL-3.0 license copy alongside the notice so the applicable terms are available on-device.
## [0.3.15-r1] - 2026-08-30
- Bump version for release.
## [0.3.14-r8] - 2026-08-30
### Fixed
- Reconcile statistics enable/disable changes without restarting the SafeShield refresh daemon.
- Add or remove only the statistics procd instance while keeping the refresh scheduler running.
- Restart dnsmasq only when the managed statistics logging configuration changes.
- Avoid transient `stage: stopped` states caused by full SafeShield restarts from statistics-only configuration updates.
## [0.3.14-r7] - 2026-08-29
### Fixed
- Initialize all per-device AWK array elements when a device record is created.
- Avoid sparse associative-array values reaching serialization helpers, preventing gawk double-free crashes on affected versions.
- Add regression coverage for complete state serialization of unidentified IP-based devices.
## [0.3.14-r6] - 2026-08-29
### Changed
- Add a shared shell lint entrypoint for shfmt, ShellCheck, and shell syntax validation.
- Run the same shell lint script from local pre-commit hooks and GitHub Actions.
- Fail local commits early when shell formatting does not satisfy `shfmt -d -ci`.
## [0.3.14-r5] - 2026-08-29
### Fixed
- Align statistics tests with the repository shfmt formatting rules.
## [0.3.14-r4] - 2026-08-29
- Add per-device DNS query and block counters to local SafeShield statistics.
- Use `log-queries=extra` so dnsmasq block responses can be attributed to the requesting client.
- Resolve DHCP clients from the configured dnsmasq lease file and use MAC addresses as stable local device identities.
- Fall back to temporary IP identities for clients without a DHCP lease and migrate them when a lease becomes available.
- Cap retained device identities at 128 while keeping raw queried domains out of statistics state.
## [0.3.14-r3] - 2026-08-29
- Fix `shfmt -ci` formatting for the statistics collector regression test environment assignments.
## [0.3.14-r2] - 2026-08-29
- Fix orphaned `logread` / `awk` statistics collector processes after procd restarts or service termination.
- Track collector child PIDs explicitly and terminate them on HUP, INT, TERM, and normal exit.
- Use a per-instance FIFO instead of an unmanaged shell pipeline so the parent process owns the full collector lifecycle.
- Add a tmpfs collector lock to prevent concurrent statistics writers and recover stale locks after crashes.
- Add regression coverage that verifies collector children and runtime files are cleaned up after termination.
## [0.3.14-r1] - 2026-08-29
- Add lightweight local DNS statistics collection using dnsmasq query logs.
- Keep statistics in tmpfs only to avoid persistent flash writes.
- Add hourly query/block counters with configurable snapshots and up to 168 hours of retention.
- Add the `safeshield.statistics` ubus RPC endpoint for local dashboards.
- Enable asynchronous dnsmasq query logging only while statistics collection is enabled.
## [0.3.13-r1] - 2026-08-28
- Bump version for release.
## [0.3.12-r2] - 2026-08-28
- Fix ShellCheck warnings in the dnsmasq compatibility helper and regression test scripts.
- Encapsulate the dnsmasq compatibility failure code behind a helper instead of exposing a cross-file global variable.
- Use an explicit empty `CDPATH` assignment in test path resolution and remove an unused test variable.
## [0.3.12-r1] - 2026-08-28
- Require dnsmasq 2.80 or later before SafeShield starts, refreshes, or reapplies local rules.
- Expose the detected and minimum dnsmasq versions through runtime status and health checks.
- Accept optimized Hub artifacts using `address=/domain/#` and emit the same single-line dual-stack rule in the active dnsmasq blocklist.
- Keep blocklist verification compatible with both the optimized `/#` rule and legacy `0.0.0.0` / `::` rules.
## [0.3.11-r35] - 2026-08-27
- Declare `coreutils-cksum` as a runtime dependency because SafeShield uses `cksum` to fingerprint normalized local allow/block rules.
- Ensure the existing identity hash fallback also has a guaranteed `cksum` implementation on minimal OpenWrt images such as GL-MT300N-V2.
- Fix false dnsmasq restart/runtime failures on OpenWrt targets where `pgrep -x dnsmasq` does not match the running dnsmasq process.
- Use the base-system `pidof` applet for dnsmasq process detection, while retaining the existing DNS query readiness check.
- Declare direct runtime dependencies for the `uci` CLI and `jsonfilter` used by SafeShield shell helpers.
- Use BusyBox-aware fallback dependencies for `sha256sum`, `awk`, `grep`, and `sed` so minimal OpenWrt images install GNU implementations only when the corresponding BusyBox applet is disabled.
- Remove the invalid `cksum` fallback from identity SHA-256 generation; CRC output is not a SHA-256 digest and cannot satisfy the identity format.
- Fail closed when artifact SHA-256 verification cannot run instead of silently accepting an unverified artifact.
## [0.3.10-r34] - 2026-08-26
- Refactor the 1,300+ line rpcd ucode implementation into focused modules under `/usr/share/rpcd/ucode/safeshield/`.
- Keep all SafeShield rpcd ucode sources together under the rpcd plugin tree instead of installing private modules under `/usr/share/ucode`.
- Keep `/usr/share/rpcd/ucode/safeshield.uc` as a small ubus registration entrypoint while preserving existing RPC names, arguments, and response schemas.
- Separate shared UCI/helpers, runtime lifecycle, status, configuration, local rules, refresh, and license logic to reduce coupling.
- Treat an absent `license_key` UCI option as the canonical unlicensed state and clear it explicitly with `uci.delete()` instead of relying on the ucode UCI empty-string deletion side effect.
- Add `safeshield.license_get` for explicit authenticated retrieval of the configured raw license key while keeping normal status/config responses masked.
- Keep license removal on `safeshield.license_update` with an empty key and document the refresh/unlicensed transition.
## [0.3.9-r29] - 2026-08-19
- Apply local allow/block rule mutations from the retained normalized Hub artifact instead of re-resolving and re-downloading the full artifact.
- Serialize local rule application with the full refresh lock and wait for an in-flight refresh before merging the newest local files.
- Fingerprint normalized local rules so rapid duplicate apply workers collapse without repeated dnsmasq restarts.
- Fall back to a full refresh only when the cached Hub artifact is unavailable.
- Expose `last_local_apply` and `last_local_apply_failure` timestamps while keeping the normal Hub refresh schedule unchanged.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+179
View File
@@ -0,0 +1,179 @@
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
include $(TOPDIR)/rules.mk
PKG_NAME:=safeshield
PKG_VERSION:=0.3.24
PKG_RELEASE:=1
PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com>
PKG_LICENSE:=GPL-3.0-or-later
PKG_LICENSE_FILES:=LICENSE
include $(INCLUDE_DIR)/package.mk
define Package/safeshield
SECTION:=net
CATEGORY:=Network
TITLE:=SafeShield DNS Block Service
URL:=https://github.com/Junatum/safeshield
DEPENDS:= \
+jshn \
+jsonfilter \
+uci \
+uclient-fetch \
+dnsmasq \
+gzip \
+coreutils-sort \
+coreutils-cksum \
+!BUSYBOX_DEFAULT_SHA256SUM:coreutils-sha256sum \
+!BUSYBOX_DEFAULT_AWK:gawk \
+!BUSYBOX_DEFAULT_GREP:grep \
+!BUSYBOX_DEFAULT_SED:sed \
+procd \
+rpcd \
+rpcd-mod-ucode \
+ucode \
+ucode-mod-fs \
+ucode-mod-ubus \
+ucode-mod-uci
EXTRA_DEPENDS:=dnsmasq (>=2.93)
PKGARCH:=all
endef
define Package/safeshield/description
SafeShield is a Lightweight, DNS-based protection for OpenWrt — block ads and phishing sites.
endef
define Package/safeshield/conffiles
/etc/config/safeshield
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/safeshield/install
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/safeshield $(1)/etc/init.d/safeshield
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./files/usr/libexec/safeshield-refreshd $(1)/usr/libexec/safeshield-refreshd
$(INSTALL_BIN) ./files/usr/libexec/safeshield-statsd $(1)/usr/libexec/safeshield-statsd
$(INSTALL_BIN) ./files/usr/libexec/safeshield-stats-poll $(1)/usr/libexec/safeshield-stats-poll
$(INSTALL_BIN) ./files/usr/libexec/safeshield-stats-identities $(1)/usr/libexec/safeshield-stats-identities
$(INSTALL_BIN) ./files/usr/libexec/safeshield-statistics-uploader $(1)/usr/libexec/safeshield-statistics-uploader
$(INSTALL_DIR) $(1)/etc/config
$(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_DIR) $(1)/usr/lib/safeshield
$(INSTALL_BIN) ./files/usr/lib/safeshield/core.sh $(1)/usr/lib/safeshield/core.sh
$(INSTALL_BIN) ./files/usr/lib/safeshield/log.sh $(1)/usr/lib/safeshield/log.sh
$(INSTALL_BIN) ./files/usr/lib/safeshield/status.sh $(1)/usr/lib/safeshield/status.sh
$(INSTALL_BIN) ./files/usr/lib/safeshield/utils.sh $(1)/usr/lib/safeshield/utils.sh
$(INSTALL_DATA) ./files/usr/lib/safeshield/config.sh $(1)/usr/lib/safeshield/config.sh
$(INSTALL_BIN) ./files/usr/lib/safeshield/identity.sh $(1)/usr/lib/safeshield/identity.sh
$(INSTALL_DATA) ./files/usr/lib/safeshield/dns.sh $(1)/usr/lib/safeshield/dns.sh
$(INSTALL_DATA) ./files/usr/lib/safeshield/blocklist.sh $(1)/usr/lib/safeshield/blocklist.sh
$(INSTALL_DATA) ./files/usr/lib/safeshield/status-store.uc $(1)/usr/lib/safeshield/status-store.uc
$(INSTALL_DATA) ./files/usr/lib/safeshield/statistics.sh $(1)/usr/lib/safeshield/statistics.sh
$(INSTALL_DIR) $(1)/usr/lib/safeshield/statistics
$(INSTALL_DATA) ./files/usr/lib/safeshield/statistics/*.awk $(1)/usr/lib/safeshield/statistics/
echo '$(PKG_VERSION)-r$(PKG_RELEASE)' > $(1)/usr/lib/safeshield/version
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
$(INSTALL_BIN) ./files/usr/share/rpcd/ucode/safeshield.uc $(1)/usr/share/rpcd/ucode/safeshield.uc
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode/safeshield
$(INSTALL_DATA) ./files/usr/share/rpcd/ucode/safeshield/*.uc $(1)/usr/share/rpcd/ucode/safeshield/
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./files/usr/share/rpcd/acl.d/safeshield.json $(1)/usr/share/rpcd/acl.d/safeshield.json
$(INSTALL_DIR) $(1)/lib/upgrade/keep.d
$(INSTALL_DATA) ./files/lib/upgrade/keep.d/safeshield $(1)/lib/upgrade/keep.d/safeshield
$(INSTALL_DIR) $(1)/usr/share/licenses/safeshield
$(INSTALL_DATA) ./LICENSE $(1)/usr/share/licenses/safeshield/GPL-3.0.txt
$(INSTALL_DATA) ./THIRD_PARTY_NOTICES.md $(1)/usr/share/licenses/safeshield/THIRD_PARTY_NOTICES.md
endef
define Package/safeshield/postinst
#!/bin/sh
IPKG_INSTROOT="$${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
PKG_NAME='safeshield'
export PKG_NAME
if [ -z "$${IPKG_INSTROOT}" ]; then
mkdir -p /etc/safeshield
if [ -r /usr/lib/safeshield/identity.sh ]; then
(
# apk may execute package lifecycle scripts with nounset enabled.
# Runtime helpers are intentionally compatible with normal OpenWrt
# /bin/sh semantics, so isolate them from the package-manager shell.
set +u
. /lib/functions.sh 2>/dev/null || true
. /lib/functions/system.sh 2>/dev/null || true
. /usr/lib/safeshield/utils.sh 2>/dev/null || true
. /usr/lib/safeshield/log.sh 2>/dev/null || true
. /usr/lib/safeshield/status.sh 2>/dev/null || true
. /usr/lib/safeshield/config.sh 2>/dev/null || true
. /usr/lib/safeshield/identity.sh 2>/dev/null || true
. /usr/lib/safeshield/blocklist.sh 2>/dev/null || true
ss_load_config >/dev/null 2>&1 || true
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
if [ ! -f /etc/safeshield/allowlist ]; then
cat << 'EOF' > /etc/safeshield/allowlist
# SafeShield allowlist
# Add domains to always allow (one per line)
EOF
fi
# Create default blocklist if not exists
if [ ! -f /etc/safeshield/blocklist ]; then
cat << 'EOF' > /etc/safeshield/blocklist
# SafeShield blocklist
# Add domains to always block (one per line)
EOF
fi
chmod 644 /etc/safeshield/allowlist
chmod 644 /etc/safeshield/blocklist
# Enable and restart service
/etc/init.d/safeshield enable >/dev/null 2>&1 || true
/etc/init.d/safeshield restart >/dev/null 2>&1 || true
# Reload rpcd for LuCI integration
if [ -x /etc/init.d/rpcd ]; then
/etc/init.d/rpcd reload >/dev/null 2>&1 || \
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
fi
fi
exit 0
endef
define Package/safeshield/prerm
#!/bin/sh
IPKG_INSTROOT="$${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
if [ -z "$${IPKG_INSTROOT}" ]; then
echo -n "Stopping safeshield service... "
/etc/init.d/safeshield stop >/dev/null 2>&1 && echo "OK" || echo "FAIL"
echo -n "Removing rc.d symlink for safeshield... "
/etc/init.d/safeshield disable >/dev/null 2>&1 && echo "OK" || echo "FAIL"
fi
exit 0
endef
$(eval $(call BuildPackage,safeshield))
+671
View File
@@ -0,0 +1,671 @@
# SafeShield
[![Lint](https://github.com/Junatum/safeshield/actions/workflows/lint-shell.yml/badge.svg)](https://github.com/Junatum/safeshield/actions/workflows/lint-shell.yml)
![OpenWrt](https://img.shields.io/badge/OpenWrt-Compatible-blue)
![License](https://img.shields.io/github/license/Junatum/safeshield?label=License)
SafeShield is a lightweight DNS protection engine for OpenWrt and the core network-protection component used by **SmartSafeHub**. It blocks advertising, tracking, and phishing domains at the router through dnsmasq, so connected devices can benefit without installing a separate client application.
For most users, the recommended way to run SafeShield is through the **SmartSafeHub firmware and Web UI**. The standalone `luci-app-safeshield` package remains available for users who want a dedicated SafeShield-only LuCI interface, but current product UI development is focused on [`luci-app-smartsafehub`](https://github.com/Junatum/luci-app-smartsafehub).
- SmartSafeHub: https://www.smartsafehub.com/
- Firmware downloads: https://www.smartsafehub.com/firmware/
- Installation guide: https://www.smartsafehub.com/docs/installation/
## Features
- DNS-based blocking of ads, tracking, and phishing domains
- Fully compatible with **dnsmasq**
- Lightweight design suitable for **low-resource OpenWrt devices**
- Integrated management through the actively developed **SmartSafeHub Web UI**
- Optional standalone management through `luci-app-safeshield`
- Automatic blocklist download and refresh
- Multiple Hub artifact sources with independent block/allow actions and checksum verification
- Support for **custom allowlist and blocklist**
- Modular shell-based architecture for easy customization and maintenance
- Lean runtime surface with regression coverage guarding retired/unused shell helpers from returning
## Package versions
The badges below show the versions currently published to each repository channel.
| Package | Stable | Beta |
| --- | --- | --- |
| SafeShield | [![Stable SafeShield](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frepo.smartsafehub.com%2Fstable%2Fversions.json&query=%24.packages%5B%22safeshield%22%5D&label=&color=brightgreen&cacheSeconds=300)](https://repo.smartsafehub.com/stable/versions.json) | [![Beta SafeShield](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frepo.smartsafehub.com%2Fbeta%2Fversions.json&query=%24.packages%5B%22safeshield%22%5D&label=&color=orange&cacheSeconds=300)](https://repo.smartsafehub.com/beta/versions.json) |
| SmartSafeHub UI | [![Stable SmartSafeHub UI](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frepo.smartsafehub.com%2Fstable%2Fversions.json&query=%24.packages%5B%22luci-app-smartsafehub%22%5D&label=&color=brightgreen&cacheSeconds=300)](https://repo.smartsafehub.com/stable/versions.json) | [![Beta SmartSafeHub UI](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frepo.smartsafehub.com%2Fbeta%2Fversions.json&query=%24.packages%5B%22luci-app-smartsafehub%22%5D&label=&color=orange&cacheSeconds=300)](https://repo.smartsafehub.com/beta/versions.json) |
| Standalone SafeShield UI | [![Stable LuCI SafeShield](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frepo.smartsafehub.com%2Fstable%2Fversions.json&query=%24.packages%5B%22luci-app-safeshield%22%5D&label=&color=brightgreen&cacheSeconds=300)](https://repo.smartsafehub.com/stable/versions.json) | [![Beta LuCI SafeShield](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frepo.smartsafehub.com%2Fbeta%2Fversions.json&query=%24.packages%5B%22luci-app-safeshield%22%5D&label=&color=orange&cacheSeconds=300)](https://repo.smartsafehub.com/beta/versions.json) |
`luci-app-smartsafehub` is the primary user-facing UI and the focus of current product development. `luci-app-safeshield` is retained as an optional standalone SafeShield interface and is not the primary target for new product UI work.
## How it works
SafeShield provides DNS-level protection by automatically applying the
appropriate **dnsmasq**-compatible blocklist for each device.
It helps block ads, trackers, and phishing domains with scheduled updates
and optional local allow/block overrides.
### Hub artifact source contract
SafeShield accepts both the legacy single-artifact response and the preferred
multi-source response from the SmartSafeHub Hub API. Existing deployments can
continue returning `artifact.download_url`.
For multiple independently licensed or managed datasets, the Hub should return
`artifact.sources`:
```json
{
"artifact": {
"tier": "pro",
"version": "20260830T120000Z",
"sources": [
{
"id": "hagezi-pro",
"action": "block",
"download_url": "https://example.invalid/hagezi-pro.txt",
"sha256": "..."
},
{
"id": "smartsafehub-pro",
"action": "block",
"download_url": "https://example.invalid/smartsafehub-pro.txt",
"sha256": "..."
},
{
"id": "smartsafehub-allow",
"action": "allow",
"download_url": "https://example.invalid/smartsafehub-allow.txt",
"sha256": "..."
}
]
}
}
```
Each source is downloaded, checksum-verified, normalized, and retained as an
independent runtime cache file. SafeShield merges all block and allow sources
only on the router when generating the active dnsmasq configuration. This keeps
the Hub artifacts separate while preserving override precedence as `local allow`
> `local block` > `Hub allow` > `Hub block`.
SafeShield also sends a canonical `device.device_code` with Hub resolve requests.
SmartSafeHub firmware is authoritative when `/usr/share/smartsafehub/firmware.json`
contains a valid `device_code`. Standalone SafeShield installations fall back only
to exact OpenWrt board-name mappings for officially recognized hardware; unknown
hardware sends an empty `device_code` instead of guessing from vendor, model,
architecture, or memory. `device.device_code_source` reports
`smartsafehub_firmware`, `board`, or `unknown` for diagnostics. The same values
are exposed through `ubus call safeshield status`.
## System Requirements
SafeShield requires the following environment:
- **OpenWrt 25.12 or later**
- **dnsmasq 2.93 or later** (the SafeShield dnsmasq extension enables cumulative statistics; stock dnsmasq falls back to standard block rules with statistics disabled)
- At least **16 MB free flash storage**
- Internet access for downloading blocklists
## Installation
### Recommended: SmartSafeHub firmware
For supported hardware, the recommended installation path is a complete **SmartSafeHub OpenWrt firmware image**. This gives users the integrated SmartSafeHub Web UI together with SafeShield and the firmware-specific integration expected by the product.
Download firmware without signing in:
https://www.smartsafehub.com/firmware/
The firmware page currently provides releases for supported devices including:
- **ipTIME AX3000SM**
- **GL.iNet GL-MT300N-V2**
- **Xiaomi Router AX3000T (International version)**
Choose the image type that matches the installation state:
- **Factory** — for an initial installation from vendor firmware or a device-specific recovery flow.
- **Sysupgrade** — for updating a router that already runs SmartSafeHub/OpenWrt.
Always verify the exact hardware model and follow the device-specific installation guide before flashing. Installing an image for a different model can prevent the router from booting.
See the installation guide for the current procedure:
https://www.smartsafehub.com/docs/installation/
### Advanced: install SafeShield packages on OpenWrt
SafeShield is also published through the SmartSafeHub OpenWrt package repository for advanced users, development environments, and compatible custom OpenWrt installations. OpenWrt 25.12 uses the `apk` package manager.
Add the SmartSafeHub signing key and Stable repository, replacing `<architecture>` with the target package architecture such as `aarch64_cortex-a53`, `mipsel_24kc`, or `x86_64`:
```sh
mkdir -p /etc/apk/keys /etc/apk/repositories.d
uclient-fetch -O /etc/apk/keys/smartsafehub.pem \
https://repo.smartsafehub.com/stable/packages/<architecture>/smartsafehub/smartsafehub.pem
printf '%s\n' \
'https://repo.smartsafehub.com/stable/packages/<architecture>/smartsafehub/packages.adb' \
> /etc/apk/repositories.d/smartsafehub.list
apk update
```
Install SafeShield with the primary SmartSafeHub UI:
```sh
apk add safeshield luci-app-smartsafehub
```
If you specifically want the older standalone SafeShield-only LuCI interface, it remains available as an optional package:
```sh
apk add luci-app-safeshield
```
The standalone UI is not the primary target for new product UI development. New dashboard, device-management, firmware-update, and broader SmartSafeHub features are developed in `luci-app-smartsafehub`.
After installation, restart the relevant services if they are not already restarted by the package lifecycle:
```sh
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
/etc/init.d/safeshield restart
```
Verify SafeShield:
```sh
ubus call safeshield status
/etc/init.d/safeshield status
logread | grep -i safeshield
```
## Build from source
SafeShield is built as an OpenWrt package. Build it inside an OpenWrt buildroot or SDK that matches your target device and OpenWrt version.
### Build with OpenWrt buildroot
Clone OpenWrt and prepare feeds:
```sh
git clone -b production https://github.com/Junatum/openwrt.git
cd openwrt
./scripts/feeds update -a
./scripts/feeds install -a
```
Add SafeShield under the OpenWrt `package/` directory:
```sh
git clone https://github.com/Junatum/safeshield package/safeshield
```
If you also want the current SmartSafeHub product UI, add
`luci-app-smartsafehub`:
```sh
git clone https://github.com/Junatum/luci-app-smartsafehub package/luci-app-smartsafehub
```
The standalone `luci-app-safeshield` repository is still available, but it is not the primary target for new UI development. Clone it only when you intentionally need the dedicated SafeShield-only interface.
Select your target device:
```sh
make menuconfig
```
For example, for MediaTek Filogic devices such as ipTIME AX3000SM:
```text
Target System ---> MediaTek Ralink ARM
Subtarget ---> Filogic 8x0
Target Profile ---> your device profile
```
Enable SafeShield as a module:
```sh
cat >> .config <<'EOF'
CONFIG_PACKAGE_safeshield=m
CONFIG_PACKAGE_luci-app-smartsafehub=m
EOF
make defconfig
```
Build the packages:
```sh
make package/safeshield/clean V=s
make package/safeshield/compile V=s
make package/luci-app-smartsafehub/clean V=s
make package/luci-app-smartsafehub/compile V=s
```
Find the generated packages:
```sh
find bin -type f \( \
-name 'safeshield*.apk' \
-o -name 'luci-app-smartsafehub*.apk' \
\) -print
```
Depending on the OpenWrt version and package location, the output can appear
under one of these paths:
```text
bin/targets/<target>/<subtarget>/packages/
bin/packages/<architecture>/<feed-name>/
```
For example, a MediaTek Filogic `apk` build may generate packages under:
```text
bin/targets/mediatek/filogic/packages/
```
### Build with OpenWrt SDK
You can also build SafeShield with the OpenWrt SDK for your target. This is
faster when you only need package artifacts.
```sh
tar xf openwrt-sdk-*.tar.*
cd openwrt-sdk-*
./scripts/feeds update -a
./scripts/feeds install -a
git clone https://github.com/Junatum/safeshield package/safeshield
git clone https://github.com/Junatum/luci-app-smartsafehub package/luci-app-smartsafehub
cat >> .config <<'EOF'
CONFIG_PACKAGE_safeshield=m
CONFIG_PACKAGE_luci-app-smartsafehub=m
EOF
make defconfig
make package/safeshield/compile V=s
make package/luci-app-smartsafehub/compile V=s
```
### Install local build artifacts
For OpenWrt `apk` based builds, copy the generated artifacts to the
device:
```sh
find bin -type f \( -name 'safeshield-*.apk' -o -name 'luci-app-smartsafehub-*.apk' \) -print
scp path/to/safeshield-*.apk root@192.168.1.1:/tmp/
scp path/to/luci-app-smartsafehub-*.apk root@192.168.1.1:/tmp/
```
Install them on the device:
```sh
ssh root@192.168.1.1
apk add --allow-untrusted /tmp/safeshield-*.apk
apk add --allow-untrusted /tmp/luci-app-smartsafehub-*.apk
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
/etc/init.d/safeshield restart
```
Verify the installation:
```sh
ubus call safeshield status
/etc/init.d/safeshield status
logread | grep -i safeshield
```
## Development linting
SafeShield uses the same shell lint entrypoint locally and in GitHub Actions:
```sh
sh scripts/lint.sh
```
The lint script runs `shfmt -d -ci`, ShellCheck, and `sh -n` against tracked shell files. Install `shfmt` and `shellcheck` on the development machine before running it.
ShellSpec support helpers are linted by the same command; mocks consumed by dynamically sourced production functions are exported explicitly so the test harness remains ShellCheck-clean without changing runtime behavior.
Run the regression suite separately when changing runtime, statistics, HTTP transport, or rpcd behavior:
```sh
REQUIRE_UCODE=1 shellspec
```
The suite includes full refresh/local-rule rollback state machines, refresh lock contention, init lifecycle transitions, artifact retry/size/SHA-256 integrity checks, refreshd scheduling, OpenWrt `uclient-fetch` authenticated POST handling, statistics uploader recovery/state transitions, artifact resolve identity payloads, and direct rpcd runtime/status-store validation paths.
The refresh-lock regression uses a test-only portable lock shim, so the suite does not require a host `flock(1)` binary on macOS; production SafeShield continues to use OpenWrt's `flock`.
To enable the repository pre-commit hook, install `pre-commit` and register the hook once:
```sh
pre-commit install
```
After that, `git commit` runs the shell lint automatically. You can also check the whole repository manually:
```sh
pre-commit run --all-files
```
## Internal rpcd ucode layout
SafeShield keeps the rpcd registration entrypoint intentionally small and loads feature modules from `/usr/share/rpcd/ucode/safeshield/`:
```text
/usr/share/rpcd/ucode/safeshield.uc
/usr/share/rpcd/ucode/safeshield/
├── core.uc
├── runtime.uc
├── status.uc
├── config.uc
├── refresh.uc
├── rules.uc
├── license.uc
└── statistics.uc
```
The entrypoint prepends `/usr/share/rpcd/ucode/safeshield/*.uc` to ucode's `REQUIRE_SEARCH_PATH`, so the private RPC modules stay colocated with the rpcd plugin instead of using the global `/usr/share/ucode` tree.
The public ubus contract remains exposed through the single `safeshield` object; the module split is an internal implementation detail.
## SafeShield ubus API
The `safeshield` package owns the public management API used by LuCI and
SmartSafeHub clients. The API keeps UCI mutations, service lifecycle,
refresh scheduling and local rule files behind one ubus object.
Available methods:
```text
safeshield.status
safeshield.config
safeshield.statistics
safeshield.config_update
safeshield.set_enabled
safeshield.refresh
safeshield.rules_list
safeshield.rule_add
safeshield.rule_delete
safeshield.license_get
safeshield.license_update
```
Read the current public configuration:
```sh
ubus call safeshield config
```
Read lightweight local DNS statistics:
```sh
ubus call safeshield statistics
/etc/init.d/safeshield statistics
```
Statistics are collected from dnsmasq cumulative SafeShield UBus counters. Raw queried domains
are never written to the statistics state. Loopback (`127.0.0.0/8` and `::1`)
queries are excluded so SafeShield's own DNS health checks do not inflate user
query or blocked counters. In addition to global hourly query/block counters,
SafeShield keeps per-device totals and per-device hourly buckets locally. DHCP
clients are identified by MAC address using the dnsmasq lease file, with the
current IP and hostname included for the local UI. The collector also retains a
non-MAC-derived DHCP client-id when one is available. IPv4 clients can be
resolved through the kernel ARP table, while IPv6 clients combine the kernel NDP
neighbor cache (`ip -6 neigh show`) with odhcpd `ipv6leases` DUID metadata. A
stable DHCP client-id or DHCPv6 DUID therefore moves retained history from an
older private/randomized MAC to the currently observed MAC instead of exposing
both MACs as separate devices. Identified clients include these stable identifiers
in the Hub statistics payload as `identities[]` entries (`dhcp_client_id`,
`dhcpv6_duid`, and `mac`) so the backend can preserve one canonical network
client across later MAC rotations.
For older retained data that predates those strong identifiers, SafeShield uses
a conservative hostname fallback only when exactly one active MAC advertises a
non-generic hostname and a matching historical MAC is no longer active. Generic
names such as `iPhone`, `Android`, `phone`, or `laptop` are never merged by
hostname alone. Unresolved IP-only identities remain separate internally for a
short reconciliation window, but the public statistics JSON serializes them as
one `unknown` device so IPv6 privacy addresses do not create a device record per
address. IP-only entries with no stable DHCP identity are compacted into that
local unknown bucket after six hours. Device statistics remain capped at 128
active retained identities to bound memory use on low-end routers.
Each retained statistics dataset has a stable `generation_id`. `started_at` is
the creation time of that retained dataset and is restored together with the
generation when the collector restarts. `session_started_at` is the start time
of the current collector process and therefore changes on every collector
restart. `snapshot_seq` is monotonically increasing within the generation and
is the ordering/idempotency key for Hub ingestion; wall-clock `updated_at` is
kept only as event metadata. Persistence-enabled devices reserve sequence
ranges ahead in `/etc/safeshield/statistics-sequence.tsv`, so a reboot can skip
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.
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.
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 collector implementation is split into focused AWK modules under
`/usr/lib/safeshield/statistics/` for common helpers, recovery, client identity,
aggregation, persistence, output, and the collector lifecycle. `safeshield-statsd`
loads the modules together as one AWK program, preserving the single collector
process used on resource-constrained routers.
Hot statistics live under `/tmp/safeshield/statistics/` and are retained for up
to 168 hourly buckets. Supported profiles additionally persist aggregate state
with a compact base snapshot plus hourly journal, while constrained profiles
may stay tmpfs-only. The collector no longer follows dnsmasq query logs. It
polls dnsmasq's cumulative SafeShield UBus counters at the effective snapshot
interval and calculates deltas in RAM. `instance_id` identifies the current
dnsmasq counter epoch so a dnsmasq restart can be distinguished from a normal
counter increment.
The default statistics settings are:
```text
statistics_enabled=1
statistics_snapshot_interval_s=60
statistics_retention_hours=168
```
When statistics are enabled, SafeShield calls `ubus call dnsmasq
safeshield_stats` through a small ucode poll helper. The helper accepts only
schema version 1 snapshots with a valid `instance_id`, cumulative totals, and
client array; malformed or incompatible replies are treated as poll failures
instead of silently becoming zero counters. No `log-queries` or `log-async`
setting is required. Existing pre-0.3.20-r2 statistics logging configuration is
removed once during upgrade. SafeShield persists aggregate counters only; raw
DNS query names are never collected.
`statistics_enabled` is the configured user preference. Runtime availability is
reported separately through `effective_enabled` and `collector_running`. If the
SafeShield dnsmasq extension is unavailable, SafeShield keeps the configured
preference intact, stops only the statistics runtime, and automatically resumes
collection after a compatible dnsmasq becomes available. Disabling Statistics
or SafeShield creates a rebaseline marker so the first snapshot after re-enable
is used only as a new cumulative-counter baseline; traffic from the disabled
period is not backfilled.
When the SafeShield dnsmasq extension is available, SafeShield emits block rules
as `safeshield-block=/domain/`. Only these SafeShield-owned rules increment the
dnsmasq blocked counter. If the extension is unavailable, SafeShield defensively
uses standard `address=/domain/#` rules so DNS protection remains available
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:
```sh
ubus call safeshield config_update '{
"values": {
"refresh_interval_s": 28800,
"refresh_on_boot": true,
"require_wan": true,
"apply_local_overrides": true,
"statistics_enabled": true,
"statistics_snapshot_interval_s": 60,
"statistics_retention_hours": 168
}
}'
```
Enable or disable SafeShield. This method commits the desired UCI value and
requests the full SafeShield enable/disable lifecycle. Runtime convergence is
asynchronous under procd, so a successful response reports the accepted target
state instead of returning a potentially stale status snapshot:
```sh
ubus call safeshield set_enabled '{"enabled":true}'
```
A successful response has this shape:
```json
{
"ok": true,
"changed": true,
"accepted": true,
"target_enabled": true,
"reconciled": false
}
```
Clients should poll `safeshield.status` until runtime state converges. For an
enable request, wait for `enabled=true`, `active=true` and
`runtime.refreshd_running=true`. For a disable request, wait for
`enabled=false`, `active=false` and `runtime.refreshd_running=false`.
Request an immediate refresh:
```sh
ubus call safeshield refresh
```
List local allow/block overrides:
```sh
ubus call safeshield rules_list
ubus call safeshield rules_list '{"action":"allow"}'
ubus call safeshield rules_list '{"action":"block"}'
```
Add or delete a local rule. Rule changes request an asynchronous refresh by
default so the active dnsmasq blocklist is rebuilt. For bulk edits, pass
`"refresh":false` on intermediate mutations and invoke `safeshield.refresh`
after the last mutation.
```sh
ubus call safeshield rule_add '{
"action": "block",
"domain": "example.com"
}'
ubus call safeshield rule_delete '{
"action": "block",
"domain": "example.com"
}'
```
### Local rule fast apply
`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":""}'
```
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.
## Contributors
<a href="https://github.com/Junatum/safeshield/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Junatum/safeshield" alt="Contributors">
</a>
## Support
For product information, supported devices, installation guidance, and firmware downloads, visit:
https://www.smartsafehub.com/
For SafeShield source-code issues or feature requests, open an issue on GitHub:
https://github.com/Junatum/safeshield/issues
Bug reports and pull requests are welcome.
## Third-party blocklist data
SafeShield may install SmartSafeHub artifacts derived from third-party DNS
blocklists, including [HaGeZi's DNS Blocklists](https://github.com/hagezi/dns-blocklists).
HaGeZi-derived artifacts remain subject to GNU GPL v3.0. See
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for the upstream project,
license, source inventory, and installed notice location.
## License
SafeShield is under the [GNU Public License version 3](https://www.gnu.org/licenses/gpl-3.0.html)
+29
View File
@@ -0,0 +1,29 @@
# Third-party data notices
SafeShield can download DNS blocklist artifacts from SmartSafeHub services. Some
artifacts may contain data derived from third-party blocklist projects. Those
data remain subject to their respective licenses and notices.
## HaGeZi's DNS Blocklists
- Project: HaGeZi's DNS Blocklists
- Upstream: https://github.com/hagezi/dns-blocklists
- License: GNU General Public License v3.0 (GPL-3.0)
- Upstream source inventory: https://github.com/hagezi/dns-blocklists/blob/main/sources.md
SmartSafeHub may normalize, sort, deduplicate, or convert the format of
HaGeZi-derived lists before an artifact is delivered to SafeShield. A delivered
artifact that is derived from HaGeZi's DNS Blocklists remains subject to the
GNU General Public License v3.0.
The HaGeZi project states that its lists are provided without warranty and that
individual upstream sources can have their own licenses. Refer to the upstream
repository, its disclaimer, and `sources.md` for the notices applicable to the
source data used by HaGeZi.
A copy of the GNU General Public License v3.0 is installed with SafeShield at:
`/usr/share/licenses/safeshield/GPL-3.0.txt`
This notice supplements, and does not replace, license or attribution metadata
that accompanies a downloaded blocklist artifact.
+47
View File
@@ -0,0 +1,47 @@
config safeshield 'config'
option enabled '1'
option verbosity '2'
# Device identity / profile
option device_vendor ''
option device_model ''
option device_arch ''
option device_memory_mb ''
# Local overrides
option apply_local_overrides '1'
# Artifact validation / installation
option max_blocklist_file_size_kb '30000'
option min_valid_line_count '3000'
option compress_blocklist '0'
option initial_dnsmasq_restart '0'
option dnsmasq_sanity_check '1'
# Local statistics (runtime state in RAM; supported profiles also persist aggregates)
option statistics_enabled '1'
option statistics_snapshot_interval_s '60'
option statistics_retention_hours '168'
# Download / refresh runtime
option download_timeout '10'
option download_retry '3'
option pause_timeout '20'
option boot_start_delay_s '30'
option refresh_on_boot '1'
option refresh_interval_s '28800'
option require_wan '1'
option debug '0'
config identity 'identity'
option physical_fingerprint ''
option fingerprint_version '1'
option identity_provider ''
option identity_source ''
option identity_strength ''
option identity_profile ''
option device_code ''
option device_code_source ''
option installation_id ''
option created_at ''
option updated_at ''
+417
View File
@@ -0,0 +1,417 @@
#!/bin/sh /etc/rc.common
# shellcheck shell=ash
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
# Used by /etc/rc.common
# shellcheck disable=SC2034
START=20
# shellcheck disable=SC2034
STOP=1
# shellcheck disable=SC2034
USE_PROCD=1
LC_ALL=C
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
readonly PKG_NAME='safeshield'
readonly PKG_VERSION='0.3.24-r1'
if type extra_command >/dev/null 2>&1; then
extra_command 'apply_local_rules' 'Apply local allow/block rules using the cached Hub artifact'
extra_command 'force_download' 'Resolve and download latest Hub artifact'
extra_command 'pause' 'Pause the service'
extra_command 'refresh_once' 'Run one immediate refresh in foreground'
extra_command 'reconcile_statistics' 'Reconcile SafeShield statistics runtime without restarting refreshd'
extra_command 'resume' 'Resume the service'
extra_command 'show_blocklist' 'List currently blocked domains'
extra_command 'sizes' 'Display SafeShield blocklist size information'
extra_command 'statistics' 'Display local SafeShield DNS statistics'
extra_command 'version' 'Show version information'
fi
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/lib/functions.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/core.sh"
boot() {
rc_procd start_service boot
}
start_service() {
local statistics_dnsmasq_changed=0
local blocklist_restart_expected=0
ss_load_config || {
log_error "Failed to load config"
return 1
}
ss_identity_ensure "$(ss_detect_device_model 2>/dev/null || echo OpenWrt)" "$(ss_detect_device_arch 2>/dev/null || echo unknown)" || {
log_error "Failed to initialize SafeShield device identity"
return 1
}
ss_mkdirs || {
log_error "Failed to create runtime directories"
return 1
}
if [ "${ss_enabled}" != "1" ]; then
log_warn "${PKG_NAME} is disabled"
ss_statistics_request_rebaseline || {
log_error "Failed to mark statistics for rebaseline while SafeShield is disabled"
return 1
}
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 0)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
[ -f "${SS_BLOCKLIST_FILE}" ] && blocklist_restart_expected=1
ss_runtime_deactivate_blocklist || {
log_error "Failed to deactivate SafeShield blocklist"
return 1
}
if [ "$statistics_dnsmasq_changed" = "1" ] && [ "$blocklist_restart_expected" = "0" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
ss_status_set status "disabled"
ss_status_set stage "disabled"
ss_status_set last_result "disabled"
ss_status_set next_refresh_at "0"
return 0
fi
ss_require_supported_dnsmasq || {
ss_status_set stage "dnsmasq_version_check"
ss_status_mark_failure "$(ss_dnsmasq_check_error)"
return 1
}
ss_statistics_disable_if_dnsmasq_unpatched || {
log_error "Failed to apply the dnsmasq compatibility guard for statistics"
return 1
}
if [ "${ss_statistics_enabled}" != "1" ]; then
ss_statistics_request_rebaseline || {
log_error "Failed to mark statistics for rebaseline while statistics are inactive"
return 1
}
fi
ss_reconcile_installed_blocklists_for_dnsmasq || {
log_error "Failed to reconcile installed blocklist directives for dnsmasq"
ss_status_mark_failure "blocklist_directive_migration_failed"
return 1
}
if [ "${ss_statistics_enabled}" = "1" ]; then
ss_ensure_dnsmasq_confdir || {
log_error "dnsmasq confdir is unavailable while enabling statistics"
return 1
}
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 1)" || {
log_error "Failed to reconcile legacy dnsmasq statistics logging"
return 1
}
else
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 0)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
fi
if [ -f "${SS_BLOCKLIST_FILE}" ] || [ -f "${SS_INACTIVE_BLOCKLIST_FILE}" ]; then
blocklist_restart_expected=1
fi
ss_runtime_activate_blocklist || {
log_error "Failed to activate SafeShield blocklist"
return 1
}
if [ "$statistics_dnsmasq_changed" = "1" ] && [ "$blocklist_restart_expected" = "0" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
procd_open_instance
procd_set_param command /usr/libexec/safeshield-refreshd
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param file /etc/config/safeshield
procd_set_param data service safeshield
procd_close_instance
if [ "${ss_statistics_enabled}" = "1" ]; then
ss_statistics_add_procd_instance
ss_statistics_add_upload_procd_instance
fi
}
stop_service() {
local statistics_dnsmasq_changed=0
local blocklist_restart_expected=0
ss_load_config >/dev/null 2>&1 || true
statistics_dnsmasq_changed="$(ss_statistics_configure_dnsmasq 0)" || {
log_error "Failed to remove legacy dnsmasq statistics logging while stopping service"
return 1
}
[ -f "${SS_BLOCKLIST_FILE}" ] && blocklist_restart_expected=1
ss_runtime_deactivate_blocklist || {
log_error "Failed to deactivate SafeShield blocklist while stopping service"
return 1
}
if [ "$statistics_dnsmasq_changed" = "1" ] && [ "$blocklist_restart_expected" = "0" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
ss_status_set next_refresh_at "0"
if [ "${ss_enabled:-0}" = "1" ]; then
ss_status_set status "idle"
ss_status_set stage "stopped"
ss_status_set last_result "stopped"
else
ss_status_set status "disabled"
ss_status_set stage "disabled"
ss_status_set last_result "disabled"
fi
return 0
}
reconcile_statistics() {
ss_load_config || {
log_error "Failed to load config while reconciling statistics"
return 1
}
ss_mkdirs || {
log_error "Failed to create runtime directories while reconciling statistics"
return 1
}
ss_statistics_reconcile_runtime
}
reload_service() {
stop
start
}
restart_service() {
stop
start
}
service_triggers() {
procd_add_reload_trigger 'safeshield'
procd_add_reload_interface_trigger 'wan' /etc/init.d/safeshield reload
procd_add_reload_interface_trigger 'wan6' /etc/init.d/safeshield reload
}
status_service() {
local status stage valid_line_count
local enabled refresh_interval_s refresh_on_boot require_wan boot_delay
local license_key artifact_tier artifact_version license_plan device_profile
local artifact_source_count artifact_block_source_count artifact_allow_source_count
local dnsmasq_version dnsmasq_min_version
local statistics_enabled statistics_snapshot_interval_s statistics_retention_hours
if ! ss_load_config >/dev/null 2>&1; then
printf '%s\n' 'config: load failed'
fi
enabled="$(ss_config_get config enabled 0)"
refresh_interval_s="$(ss_config_get config refresh_interval_s 28800)"
refresh_on_boot="$(ss_config_get config refresh_on_boot 1)"
require_wan="$(ss_config_get config require_wan 1)"
boot_delay="$(ss_config_get config boot_start_delay_s 30)"
license_key="$(ss_config_get config license_key '')"
statistics_enabled="$(ss_config_get config statistics_enabled 1)"
statistics_snapshot_interval_s="$(ss_config_get config statistics_snapshot_interval_s 60)"
statistics_retention_hours="$(ss_config_get config statistics_retention_hours 168)"
status=""
stage=""
valid_line_count="0"
if [ -f "${RUNNING_STATUS_FILE}" ]; then
status="$(json get status 2>/dev/null)"
stage="$(json get stage 2>/dev/null)"
valid_line_count="$(json get valid_line_count 2>/dev/null)"
artifact_tier="$(json get artifact_tier 2>/dev/null)"
artifact_version="$(json get artifact_version 2>/dev/null)"
artifact_source_count="$(json get artifact_source_count 2>/dev/null)"
artifact_block_source_count="$(json get artifact_block_source_count 2>/dev/null)"
artifact_allow_source_count="$(json get artifact_allow_source_count 2>/dev/null)"
license_plan="$(json get license_plan 2>/dev/null)"
device_profile="$(json get device_profile 2>/dev/null)"
dnsmasq_version="$(json get dnsmasq_version 2>/dev/null)"
dnsmasq_min_version="$(json get dnsmasq_min_version 2>/dev/null)"
fi
[ -n "$status" ] || status="idle"
[ -n "$valid_line_count" ] || valid_line_count="0"
printf 'enabled: %s\n' "$enabled"
printf 'refresh_interval_s: %s\n' "$refresh_interval_s"
printf 'refresh_on_boot: %s\n' "$refresh_on_boot"
printf 'require_wan: %s\n' "$require_wan"
printf 'boot_start_delay_s: %s\n' "$boot_delay"
printf 'license_key: %s\n' "$(ss_mask_secret "$license_key")"
if [ -f "${SS_BLOCKLIST_FILE}" ]; then
printf '%s\n' 'blocklist: installed'
else
printf '%s\n' 'blocklist: not installed'
fi
if check_dns_runtime; then
printf '%s\n' 'dnsmasq: ok'
else
printf '%s\n' 'dnsmasq: failed'
fi
[ -n "$dnsmasq_version" ] && printf 'dnsmasq_version: %s\n' "$dnsmasq_version"
printf 'dnsmasq_min_version: %s\n' "${dnsmasq_min_version:-$SS_MIN_DNSMASQ_VERSION}"
printf 'status: %s\n' "$status"
[ -n "$stage" ] && printf 'stage: %s\n' "$stage"
[ -n "$license_plan" ] && printf 'license_plan: %s\n' "$license_plan"
[ -n "$device_profile" ] && printf 'device_profile: %s\n' "$device_profile"
[ -n "$artifact_tier" ] && printf 'artifact_tier: %s\n' "$artifact_tier"
[ -n "$artifact_version" ] && printf 'artifact_version: %s\n' "$artifact_version"
[ -n "$artifact_source_count" ] && printf 'artifact_source_count: %s\n' "$artifact_source_count"
[ -n "$artifact_block_source_count" ] && printf 'artifact_block_source_count: %s\n' "$artifact_block_source_count"
[ -n "$artifact_allow_source_count" ] && printf 'artifact_allow_source_count: %s\n' "$artifact_allow_source_count"
printf 'valid_line_count: %s\n' "$valid_line_count"
printf 'statistics_enabled: %s\n' "$statistics_enabled"
printf 'statistics_snapshot_interval_s: %s\n' "$statistics_snapshot_interval_s"
printf 'statistics_retention_hours: %s\n' "$statistics_retention_hours"
if procd_running safeshield statistics; then
printf 'statistics_collector: running\n'
else
printf 'statistics_collector: stopped\n'
fi
if procd_running safeshield; then
printf '%s\n' 'refreshd: running'
else
printf '%s\n' 'refreshd: stopped'
fi
}
apply_local_rules() {
ss_load_config || return 1
ss_mkdirs || return 1
safeshield_apply_local_rules
}
refresh_once() {
ss_load_config || return 1
ss_mkdirs || return 1
safeshield_force_download
}
force_download() {
refresh_once "$@"
}
pause() {
local timeout
ss_load_config || return 1
timeout="${1:-$ss_pause_timeout}"
if ! is_valid_integer "$timeout"; then
log_error "Invalid pause timeout: ${timeout}"
return 1
fi
log_info "Pausing ${PKG_NAME} for ${timeout} seconds"
ss_export_existing_blocklist >/dev/null 2>&1 || true
rm -f "${SS_BLOCKLIST_FILE}"
dnsmasq_restart
sleep "$timeout"
log_info "Resuming ${PKG_NAME} after pause"
ss_restore_previous_blocklist >/dev/null 2>&1 || true
dnsmasq_restart
}
resume() {
log_info "Resuming ${PKG_NAME}"
ss_restore_previous_blocklist >/dev/null 2>&1 || true
dnsmasq_restart
}
show_blocklist() {
if [ -f "${SS_BLOCKLIST_FILE}" ]; then
cat "${SS_BLOCKLIST_FILE}"
else
printf '%s\n' 'No installed blocklist'
fi
}
sizes() {
local blocklist_file_size_kb backup_file_size_kb valid_line_count
ss_load_config || return 1
blocklist_file_size_kb="0"
backup_file_size_kb="0"
valid_line_count="0"
if [ -f "${SS_BLOCKLIST_FILE}" ]; then
blocklist_file_size_kb="$(du -k "${SS_BLOCKLIST_FILE}" 2>/dev/null | awk '{print $1}')"
valid_line_count="$(grep -c . "${SS_BLOCKLIST_FILE}" 2>/dev/null)"
fi
if [ -f "${SS_PREV_BLOCKLIST_GZ}" ]; then
backup_file_size_kb="$(du -k "${SS_PREV_BLOCKLIST_GZ}" 2>/dev/null | awk '{print $1}')"
fi
[ -n "$blocklist_file_size_kb" ] || blocklist_file_size_kb="0"
[ -n "$backup_file_size_kb" ] || backup_file_size_kb="0"
[ -n "$valid_line_count" ] || valid_line_count="0"
printf 'max_blocklist_file_size_kb: %s\n' "$ss_max_blocklist_file_size_kb"
printf 'min_valid_line_count: %s\n' "$ss_min_valid_line_count"
printf 'blocklist_file_size_kb: %s\n' "$blocklist_file_size_kb"
printf 'backup_blocklist_file_size_kb: %s\n' "$backup_file_size_kb"
printf 'valid_line_count: %s\n' "$valid_line_count"
}
statistics() {
if [ -s "${SS_STATISTICS_JSON_FILE}" ]; then
cat "${SS_STATISTICS_JSON_FILE}"
else
printf '%s\n' '{"schema":{"name":"safeshield.statistics","version":3},"volatile":true,"generation_id":"","snapshot_seq":0,"totals":{"queries":0,"blocked":0},"hourly":[],"devices":[]}'
fi
}
version() {
printf '%s\n' "$PKG_VERSION"
}
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
set -e
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
PKG_NAME='safeshield'
export PKG_NAME
# Initialize SafeShield identity once during first boot/package install.
# The script is also safe to run repeatedly because identity-init never
# regenerates an existing identity.env.
if [ -r /lib/functions.sh ]; then
# shellcheck disable=SC1091
. /lib/functions.sh
fi
if [ -r /lib/functions/system.sh ]; then
# shellcheck disable=SC1091
. /lib/functions/system.sh
fi
if [ -r /usr/lib/safeshield/utils.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/utils.sh
fi
if [ -r /usr/lib/safeshield/log.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/log.sh
fi
if [ -r /usr/lib/safeshield/status.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/status.sh
fi
if [ -r /usr/lib/safeshield/config.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/config.sh
ss_load_config >/dev/null 2>&1 || true
fi
if [ -r /usr/lib/safeshield/blocklist.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/blocklist.sh
fi
if command -v ss_sync_detected_device_config >/dev/null 2>&1; then
ss_sync_detected_device_config >/dev/null 2>&1 || true
fi
if [ -r /usr/lib/safeshield/identity.sh ]; then
# shellcheck disable=SC1091
. /usr/lib/safeshield/identity.sh
model="$(ss_detect_device_model 2>/dev/null || cat /tmp/sysinfo/model 2>/dev/null || echo OpenWrt)"
arch="$(ss_detect_device_arch 2>/dev/null || uname -m 2>/dev/null || echo unknown)"
ss_identity_ensure "$model" "$arch" >/dev/null 2>&1 || true
fi
exit 0
@@ -0,0 +1,7 @@
/etc/safeshield/identity.env
/etc/safeshield/allowlist
/etc/safeshield/blocklist
/etc/safeshield/statistics-state.tsv
/etc/safeshield/statistics-journal.tsv
/etc/safeshield/statistics-sequence.tsv
/etc/config/safeshield
File diff suppressed because it is too large. Load diff
+128
View File
@@ -0,0 +1,128 @@
# shellcheck shell=ash
# This file defines globals consumed by other sourced scripts.
# shellcheck disable=SC2034
ss_enabled="0"
ss_verbosity="2"
ss_license_key=""
ss_device_vendor=""
ss_device_model=""
ss_device_arch=""
ss_device_memory_mb=""
ss_apply_local_overrides="1"
ss_max_blocklist_file_size_kb="30000"
ss_min_valid_line_count="3000"
ss_compress_blocklist="0"
ss_initial_dnsmasq_restart="0"
ss_download_timeout="10"
ss_download_retry="3"
ss_pause_timeout="20"
ss_boot_start_delay_s="30"
ss_dnsmasq_sanity_check="1"
ss_statistics_enabled="1"
ss_statistics_snapshot_interval_s="60"
ss_statistics_retention_hours="168"
ss_valid_line_count="0"
ss_config_get() {
local section="$1"
local option="$2"
local default="$3"
local value
config_get value "$section" "$option"
if [ -n "$value" ]; then
printf '%s' "$value"
else
printf '%s' "$default"
fi
}
ss_validate_bool() {
local var_name="$1"
local default="$2"
local warning_code="$3"
local value
eval "value=\${$var_name}"
case "$value" in
0 | 1) return 0 ;;
esac
log_warn "Invalid ${var_name} '${value}', using default ${default}"
ss_status_add_warning "$warning_code"
eval "$var_name=\$default"
}
ss_validate_int() {
local var_name="$1"
local default="$2"
local warning_code="$3"
local value
eval "value=\${$var_name}"
if ! is_valid_integer "$value"; then
log_warn "Invalid ${var_name} '${value}', using default ${default}"
ss_status_add_warning "$warning_code"
eval "$var_name=\$default"
fi
}
ss_validate_config() {
ss_validate_int ss_download_timeout 10 invalid_download_timeout
ss_validate_int ss_download_retry 3 invalid_download_retry
ss_validate_int ss_max_blocklist_file_size_kb 30000 invalid_max_blocklist_file_size_kb
ss_validate_int ss_min_valid_line_count 3000 invalid_min_valid_line_count
ss_validate_int ss_pause_timeout 20 invalid_pause_timeout
ss_validate_int ss_boot_start_delay_s 30 invalid_boot_start_delay_s
ss_validate_int ss_statistics_snapshot_interval_s 60 invalid_statistics_snapshot_interval_s
ss_validate_int ss_statistics_retention_hours 168 invalid_statistics_retention_hours
ss_validate_bool ss_compress_blocklist 0 invalid_compress_blocklist
ss_validate_bool ss_initial_dnsmasq_restart 0 invalid_initial_dnsmasq_restart
ss_validate_bool ss_dnsmasq_sanity_check 1 invalid_dnsmasq_sanity_check
ss_validate_bool ss_apply_local_overrides 1 invalid_apply_local_overrides
ss_validate_bool ss_statistics_enabled 1 invalid_statistics_enabled
if [ "$ss_statistics_snapshot_interval_s" -lt 10 ] 2>/dev/null || [ "$ss_statistics_snapshot_interval_s" -gt 3600 ] 2>/dev/null; then
log_warn "statistics_snapshot_interval_s must be between 10 and 3600 seconds, using 60"
ss_status_add_warning "invalid_statistics_snapshot_interval_s"
ss_statistics_snapshot_interval_s=60
fi
if [ "$ss_statistics_retention_hours" -lt 1 ] 2>/dev/null || [ "$ss_statistics_retention_hours" -gt 168 ] 2>/dev/null; then
log_warn "statistics_retention_hours must be between 1 and 168, using 168"
ss_status_add_warning "invalid_statistics_retention_hours"
ss_statistics_retention_hours=168
fi
}
ss_load_config() {
config_load safeshield || return 1
ss_enabled="$(ss_config_get config enabled 0)"
ss_verbosity="$(ss_config_get config verbosity 2)"
ss_license_key="$(ss_config_get config license_key '')"
ss_device_vendor="$(ss_config_get config device_vendor '')"
ss_device_model="$(ss_config_get config device_model '')"
ss_device_arch="$(ss_config_get config device_arch '')"
ss_device_memory_mb="$(ss_config_get config device_memory_mb '')"
ss_apply_local_overrides="$(ss_config_get config apply_local_overrides 1)"
ss_max_blocklist_file_size_kb="$(ss_config_get config max_blocklist_file_size_kb 30000)"
ss_min_valid_line_count="$(ss_config_get config min_valid_line_count 3000)"
ss_compress_blocklist="$(ss_config_get config compress_blocklist 0)"
ss_initial_dnsmasq_restart="$(ss_config_get config initial_dnsmasq_restart 0)"
ss_download_timeout="$(ss_config_get config download_timeout 10)"
ss_download_retry="$(ss_config_get config download_retry 3)"
ss_pause_timeout="$(ss_config_get config pause_timeout 20)"
ss_boot_start_delay_s="$(ss_config_get config boot_start_delay_s 30)"
ss_dnsmasq_sanity_check="$(ss_config_get config dnsmasq_sanity_check 1)"
ss_statistics_enabled="$(ss_config_get config statistics_enabled 1)"
ss_statistics_snapshot_interval_s="$(ss_config_get config statistics_snapshot_interval_s 60)"
ss_statistics_retention_hours="$(ss_config_get config statistics_retention_hours 168)"
ss_validate_config
}
+822
View File
@@ -0,0 +1,822 @@
# shellcheck shell=ash
# This file defines globals consumed by other sourced scripts.
# shellcheck disable=SC2034
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
if [ -z "${PKG_NAME:-}" ]; then
PKG_NAME='safeshield'
fi
export PKG_NAME
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/share/libubox/jshn.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/utils.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/status.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/log.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/config.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/identity.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/dns.sh"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/blocklist.sh"
readonly SS_TMP_DIR="/tmp/safeshield"
readonly SS_DNSMASQ_DIR="/tmp/dnsmasq.d"
readonly SS_BLOCKLIST_FILE="${SS_DNSMASQ_DIR}/safeshield.blocklist"
readonly SS_INACTIVE_BLOCKLIST_FILE="${SS_TMP_DIR}/inactive.blocklist"
readonly SS_LOCAL_ALLOWLIST_FILE="/etc/safeshield/allowlist"
readonly SS_LOCAL_BLOCKLIST_FILE="/etc/safeshield/blocklist"
readonly SS_IDENTITY_DIR="/etc/safeshield"
readonly SS_IDENTITY_FILE="${SS_IDENTITY_DIR}/identity.env"
readonly SS_API_PAYLOAD="${SS_TMP_DIR}/resolve-request.json"
readonly SS_API_RESPONSE="${SS_TMP_DIR}/resolve-response.json"
readonly SS_RESOLVED_SOURCES="${SS_TMP_DIR}/resolved-sources.tsv"
readonly SS_ARTIFACT_CACHE_STATE="${SS_TMP_DIR}/artifact-sources.state"
readonly SS_MAX_ARTIFACT_SOURCES=16
readonly SS_PREV_BLOCKLIST_GZ="/tmp/safeshield.prev.blocklist.gz"
readonly SS_RUNTIME_OUT="${SS_TMP_DIR}/runtime.out"
readonly SS_REFRESH_LOCK='/var/lock/safeshield-refresh.lock'
readonly SS_REFRESH_LOCK_FD=307
readonly SS_LOCAL_APPLY_STATE="${SS_TMP_DIR}/local-apply.state"
readonly SS_STATISTICS_DIR="${SS_TMP_DIR}/statistics"
readonly SS_STATISTICS_STATE_FILE="${SS_STATISTICS_DIR}/state.tsv"
readonly SS_STATISTICS_JSON_FILE="${SS_STATISTICS_DIR}/statistics.json"
readonly SS_STATISTICS_UPLOAD_JSON_FILE="${SS_STATISTICS_DIR}/upload.json"
readonly SS_STATISTICS_UPLOAD_CREDENTIALS_FILE="${SS_STATISTICS_DIR}/upload.credentials"
readonly SS_STATISTICS_UPLOAD_ENTITLEMENT_FILE="${SS_STATISTICS_DIR}/upload.entitlement"
readonly SS_STATISTICS_UPLOAD_PENDING_FILE="${SS_STATISTICS_DIR}/upload.pending.json"
readonly SS_STATISTICS_UPLOAD_PENDING_META_FILE="${SS_STATISTICS_DIR}/upload.pending.meta"
readonly SS_STATISTICS_UPLOAD_STATE_FILE="${SS_STATISTICS_DIR}/upload.state"
readonly SS_STATISTICS_DNSMASQ_CONF="${SS_DNSMASQ_DIR}/safeshield.statistics.conf"
# shellcheck disable=SC1091
. "${IPKG_INSTROOT}/usr/lib/safeshield/statistics.sh"
ss_should_terminate="${ss_should_terminate:-0}"
ss_should_stop() {
[ "${ss_should_terminate:-0}" -eq 1 ]
}
ss_abort_refresh() {
log_warn "Termination requested, aborting current refresh"
ss_status_add_warning "refresh_terminated"
ss_status_set last_result "terminated"
ss_refresh_lock_close
return 130
}
ss_restore_and_restart() {
ss_restore_previous_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
}
ss_ensure_dnsmasq_confdir() {
local changed=0
mkdir -p "${SS_DNSMASQ_DIR}" || {
log_error "Failed to create dnsmasq confdir: ${SS_DNSMASQ_DIR}"
return 1
}
if ss_check_dnsmasq_confdir; then
return 0
fi
uci -q get dhcp.@dnsmasq[0] >/dev/null || {
log_error "dnsmasq UCI section not found: dhcp.@dnsmasq[0]"
return 1
}
log_warn "dnsmasq confdir is not set to ${SS_DNSMASQ_DIR}, attempting automatic setup"
if ! uci show dhcp | grep -Fq ".confdir='${SS_DNSMASQ_DIR}'"; then
uci add_list dhcp.@dnsmasq[0].confdir="${SS_DNSMASQ_DIR}" || {
log_error "Failed to add dhcp.@dnsmasq[0].confdir=${SS_DNSMASQ_DIR}"
return 1
}
changed=1
fi
if [ "${changed}" -eq 1 ]; then
uci commit dhcp || {
log_error "Failed to commit dhcp config"
return 1
}
log_info "Restarting dnsmasq after updating confdir"
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after configuring confdir"
return 1
}
fi
ss_check_dnsmasq_confdir || {
log_error "dnsmasq confdir verification failed after automatic setup"
return 1
}
return 0
}
ss_sync_blocklist_status() {
local file_size_kb valid_line_count
if [ ! -f "${SS_BLOCKLIST_FILE}" ]; then
ss_status_set blocklist_installed "0"
ss_status_set blocklist_file_size_kb "0"
ss_status_set valid_line_count "0"
ss_status_set blocklist_verification_ok "0"
ss_status_set health_blocklist_verify ""
return 0
fi
file_size_kb="$(du -k "${SS_BLOCKLIST_FILE}" 2>/dev/null | awk '{print $1}')"
valid_line_count="$(grep -c . "${SS_BLOCKLIST_FILE}" 2>/dev/null)"
[ -n "$file_size_kb" ] || file_size_kb="0"
[ -n "$valid_line_count" ] || valid_line_count="0"
ss_status_set blocklist_installed "1"
ss_status_set blocklist_file_size_kb "$file_size_kb"
ss_status_set valid_line_count "$valid_line_count"
}
ss_stage_active_blocklist() {
[ -f "${SS_BLOCKLIST_FILE}" ] || return 0
rm -f "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_sync_path "${SS_BLOCKLIST_FILE}"
mv -f "${SS_BLOCKLIST_FILE}" "${SS_INACTIVE_BLOCKLIST_FILE}" || return 1
ss_sync_path "${SS_DNSMASQ_DIR}"
ss_sync_path "${SS_TMP_DIR}"
return 0
}
ss_restore_staged_blocklist() {
[ -f "${SS_INACTIVE_BLOCKLIST_FILE}" ] || return 1
mv -f "${SS_INACTIVE_BLOCKLIST_FILE}" "${SS_BLOCKLIST_FILE}" || return 1
ss_sync_path "${SS_DNSMASQ_DIR}"
return 0
}
ss_runtime_deactivate_blocklist() {
local rc
ss_mkdirs || return 1
if [ ! -f "${SS_BLOCKLIST_FILE}" ]; then
ss_sync_blocklist_status
return 0
fi
log_info "Deactivating SafeShield blocklist"
ss_stage_active_blocklist || {
log_error "Failed to stage active blocklist for deactivation"
return 1
}
dnsmasq_restart
rc=$?
if [ "$rc" -ne 0 ]; then
log_error "dnsmasq restart failed while deactivating SafeShield; restoring blocklist"
ss_restore_staged_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
ss_sync_blocklist_status
return "$rc"
fi
ss_sync_blocklist_status
ss_status_set health_dnsmasq_final_restart "1"
log_ok "SafeShield blocklist deactivated"
return 0
}
ss_runtime_activate_blocklist() {
local rc
ss_mkdirs || return 1
if [ ! -f "${SS_BLOCKLIST_FILE}" ] && [ -f "${SS_INACTIVE_BLOCKLIST_FILE}" ]; then
log_info "Restoring staged SafeShield blocklist"
ss_restore_staged_blocklist || {
log_error "Failed to restore staged SafeShield blocklist"
return 1
}
fi
if [ ! -f "${SS_BLOCKLIST_FILE}" ]; then
ss_sync_blocklist_status
return 0
fi
ss_ensure_dnsmasq_confdir || {
log_error "dnsmasq confdir is unavailable while activating SafeShield"
ss_stage_active_blocklist >/dev/null 2>&1 || true
ss_sync_blocklist_status
return 1
}
dnsmasq_restart
rc=$?
if [ "$rc" -ne 0 ]; then
log_error "dnsmasq restart failed while activating SafeShield"
ss_stage_active_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
ss_sync_blocklist_status
return "$rc"
fi
check_blocklist_applied_multi_with_stats 5 3
rc=$?
if [ "$rc" -ne 0 ]; then
log_error "SafeShield blocklist is not active in dnsmasq"
ss_status_set health_blocklist_verify "0"
ss_status_set blocklist_verification_ok "0"
ss_stage_active_blocklist >/dev/null 2>&1 || true
dnsmasq_restart >/dev/null 2>&1 || true
ss_sync_blocklist_status
return "$rc"
fi
rm -f "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_sync_blocklist_status
ss_status_set health_dnsmasq_final_restart "1"
ss_status_set health_blocklist_verify "1"
ss_status_set blocklist_verification_ok "1"
log_ok "SafeShield blocklist active in dnsmasq"
return 0
}
ss_local_rules_fingerprint_from_tmp() {
{
printf '%s\n' '[allow]'
[ -f "${SS_TMP_DIR}/local.allow.txt" ] && cat "${SS_TMP_DIR}/local.allow.txt"
printf '%s\n' '[block]'
[ -f "${SS_TMP_DIR}/local.block.txt" ] && cat "${SS_TMP_DIR}/local.block.txt"
} | cksum | awk '{ print $1 ":" $2 }'
}
ss_local_rules_state_read() {
[ -s "${SS_LOCAL_APPLY_STATE}" ] || return 1
head -n 1 "${SS_LOCAL_APPLY_STATE}" 2>/dev/null
}
ss_local_rules_state_write() {
local fingerprint="$1"
local tmp="${SS_LOCAL_APPLY_STATE}.tmp.$$"
[ -n "$fingerprint" ] || return 1
printf '%s\n' "$fingerprint" >"$tmp" || return 1
ss_sync_path "$tmp"
mv -f "$tmp" "${SS_LOCAL_APPLY_STATE}" || {
rm -f "$tmp"
return 1
}
ss_sync_path "${SS_TMP_DIR}"
return 0
}
ss_prepare_local_rule_files() {
if [ "${ss_apply_local_overrides}" = "1" ]; then
ss_build_local_allowlist || return 1
ss_build_local_blocklist || return 1
else
: >"${SS_TMP_DIR}/local.allow.txt" || return 1
: >"${SS_TMP_DIR}/local.block.txt" || return 1
fi
}
ss_mark_local_apply_failure() {
local code="$1"
ss_status_set status "error"
ss_status_set stage "local_apply_failed"
ss_status_set_now last_local_apply_failure
ss_status_set last_result "error"
ss_status_set last_error_code "$code"
ss_status_add_error "$code"
}
ss_mark_local_apply_success() {
ss_status_set status "ready"
ss_status_set stage "done"
ss_status_set_now last_local_apply
ss_status_set last_result "success"
ss_status_set last_error_code ""
}
safeshield_apply_local_rules() {
local rc fingerprint applied_fingerprint
local wait_timeout=120
ss_load_config || return 1
ss_mkdirs || return 1
if [ "${ss_enabled}" != "1" ]; then
log_warn "Local rule apply skipped because SafeShield is disabled"
return 0
fi
if [ "${ss_apply_local_overrides}" != "1" ]; then
log_warn "Local rule apply skipped because apply_local_overrides is disabled"
return 0
fi
# Short engine-side debounce lets rapid rule edits converge before the first
# worker acquires the shared refresh lock. Later workers will fingerprint the
# same normalized state and exit without another dnsmasq restart.
sleep 1
log_info "Waiting for SafeShield refresh lock before applying local rules"
ss_refresh_lock_open_wait "$wait_timeout"
rc=$?
case "$rc" in
0) ;;
130) return 130 ;;
*)
log_error "Timed out waiting for SafeShield refresh lock"
ss_mark_local_apply_failure "local_apply_lock_timeout"
return 1
;;
esac
# Configuration may have changed while waiting for a full refresh.
ss_load_config || {
ss_mark_local_apply_failure "config_load_failed"
ss_refresh_lock_close
return 1
}
if [ "${ss_enabled}" != "1" ]; then
ss_refresh_lock_close
return 0
fi
if [ "${ss_apply_local_overrides}" != "1" ]; then
ss_refresh_lock_close
return 0
fi
# Normalized Hub sources are retained after a successful refresh. If the
# complete cached source set is unavailable (for example after reboot), fall
# back to one full refresh rather than reconstructing the Hub/local boundary
# from the active dnsmasq file.
if ! ss_cached_api_sources_available; then
log_warn "Cached Hub artifact is unavailable; falling back to full refresh"
ss_refresh_lock_close
safeshield_force_download
rc=$?
if [ "$rc" -ne 0 ]; then
ss_status_set_now last_local_apply_failure
fi
return "$rc"
fi
ss_status_set status "running"
ss_status_set stage "local_apply"
ss_status_set last_result "running"
ss_status_set last_error_code ""
ss_prepare_local_rule_files || {
log_error "Failed to normalize local SafeShield rules"
ss_mark_local_apply_failure "local_rules_build_failed"
ss_refresh_lock_close
return 1
}
fingerprint="$(ss_local_rules_fingerprint_from_tmp)"
applied_fingerprint="$(ss_local_rules_state_read 2>/dev/null || true)"
# Multiple rapid rule mutations may spawn several background apply workers.
# Once the newest normalized local state is active, later workers can exit
# without another dnsmasq restart.
if [ -n "$fingerprint" ] && [ "$fingerprint" = "$applied_fingerprint" ] && [ -f "${SS_BLOCKLIST_FILE}" ]; then
log_info "Local SafeShield rules are already applied; skipping duplicate apply"
ss_status_set status "ready"
ss_status_set stage "done"
ss_status_set last_result "success"
ss_status_set last_error_code ""
ss_refresh_lock_close
return 0
fi
ss_require_supported_dnsmasq || {
ss_mark_local_apply_failure "$(ss_dnsmasq_check_error)"
ss_refresh_lock_close
return 1
}
ss_ensure_dnsmasq_confdir || {
ss_status_set health_dnsmasq_confdir "0"
ss_mark_local_apply_failure "dnsmasq_confdir_not_set"
ss_refresh_lock_close
return 1
}
ss_status_set health_dnsmasq_confdir "1"
ss_export_existing_blocklist >/dev/null 2>&1 || true
if [ -f "${SS_PREV_BLOCKLIST_GZ}" ]; then
ss_status_set blocklist_backup_available "1"
fi
ss_status_set stage "local_merge"
ss_merge_lists
rc=$?
case "$rc" in
0) ;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "Failed to merge cached Hub artifact with local rules"
ss_mark_local_apply_failure "local_merge_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
ss_status_set stage "local_restart_dnsmasq"
dnsmasq_restart
rc=$?
case "$rc" in
0)
ss_status_set health_dnsmasq_final_restart "1"
;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "dnsmasq restart failed after local rule merge"
ss_status_set health_dnsmasq_final_restart "0"
ss_mark_local_apply_failure "local_dnsmasq_restart_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
ss_status_set stage "local_runtime_check"
check_dns_runtime
rc=$?
case "$rc" in
0)
ss_status_set health_dns_runtime "1"
;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "DNS runtime check failed after local rule merge"
ss_status_set health_dns_runtime "0"
ss_mark_local_apply_failure "local_dns_runtime_check_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
ss_status_set stage "local_blocklist_verify"
check_blocklist_applied_multi_with_stats 5 3
rc=$?
case "$rc" in
0)
ss_status_set health_blocklist_verify "1"
ss_status_set blocklist_verification_ok "1"
ss_local_rules_state_write "$fingerprint" || true
ss_mark_local_apply_success
rm -f "${SS_PREV_BLOCKLIST_GZ}" "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_status_set blocklist_backup_available "0"
ss_refresh_lock_close
log_ok "Local SafeShield rules applied using cached Hub artifact"
return 0
;;
130)
ss_refresh_lock_close
return 130
;;
*)
log_error "Local blocklist verification failed; restoring previous blocklist"
ss_status_set health_blocklist_verify "0"
ss_status_set blocklist_verification_ok "0"
ss_mark_local_apply_failure "local_blocklist_verification_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
}
safeshield_force_download() {
local rc refresh_interval failure_code
if ! ss_refresh_lock_open; then
log_warn "Another refresh is already running, skipping"
return 0
fi
ss_status_prepare_refresh
ss_status_set status "running"
ss_status_set stage "init"
ss_status_set_now last_attempt
ss_status_set last_result "running"
ss_status_set last_error_code ""
ss_load_config || {
ss_status_mark_failure "config_load_failed"
ss_refresh_lock_close
return 1
}
refresh_interval="$(ss_config_get config refresh_interval_s 28800)"
if ! is_valid_integer "$refresh_interval" || [ "$refresh_interval" -le 0 ] 2>/dev/null; then
refresh_interval=28800
fi
if [ "${ss_enabled}" != "1" ]; then
log_warn "SafeShield refresh skipped because the service is disabled"
ss_status_set status "disabled"
ss_status_set stage "disabled"
ss_status_set last_result "disabled"
ss_status_set next_refresh_at "0"
ss_refresh_lock_close
return 0
fi
ss_sync_detected_device_config || true
ss_identity_ensure "$(ss_detect_device_model 2>/dev/null || echo OpenWrt)" "$(ss_detect_device_arch 2>/dev/null || echo unknown)" || {
ss_status_mark_failure "identity_init_failed"
ss_refresh_lock_close
return 1
}
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_mkdirs || {
ss_status_mark_failure "mkdir_failed"
ss_refresh_lock_close
return 1
}
ss_clean_tmp
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_require_supported_dnsmasq || {
ss_status_mark_failure "$(ss_dnsmasq_check_error)"
ss_refresh_lock_close
return 1
}
ss_ensure_dnsmasq_confdir || {
log_error "dnsmasq confdir could not be configured automatically for ${SS_DNSMASQ_DIR}"
ss_status_set health_dnsmasq_confdir "0"
ss_status_mark_failure "dnsmasq_confdir_not_set"
ss_refresh_lock_close
return 1
}
ss_status_set health_dnsmasq_confdir "1"
ss_export_existing_blocklist >/dev/null 2>&1 || true
if [ -f "${SS_PREV_BLOCKLIST_GZ}" ]; then
ss_status_set blocklist_backup_available "1"
else
ss_status_set blocklist_backup_available "0"
fi
if [ "${ss_initial_dnsmasq_restart}" = "1" ]; then
dnsmasq_restart
rc=$?
case "$rc" in
0)
ss_status_set health_dnsmasq_initial_restart "1"
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "Initial dnsmasq restart failed"
ss_status_set health_dnsmasq_initial_restart "0"
ss_status_mark_failure "initial_dnsmasq_restart_failed"
ss_refresh_lock_close
return 1
;;
esac
else
ss_status_set health_dnsmasq_initial_restart ""
fi
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "resolve_api"
ss_resolve_artifact
rc=$?
case "$rc" in
0) ;;
130)
ss_abort_refresh
return $?
;;
*)
failure_code="$(ss_resolve_error_code)"
ss_status_mark_failure "$failure_code"
if [ "$failure_code" != "safeshield_upgrade_required" ]; then
ss_restore_and_restart
fi
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "download_artifact"
ss_download_api_artifacts
rc=$?
case "$rc" in
0) ;;
130)
ss_abort_refresh
return $?
;;
*)
failure_code="$(ss_artifact_download_error_code)"
ss_status_mark_failure "$failure_code"
if [ "$failure_code" != "safeshield_upgrade_required" ]; then
ss_restore_and_restart
fi
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "local_overrides"
ss_prepare_local_rule_files || {
log_error "Failed to build allowlist"
ss_status_mark_failure "allowlist_build_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
}
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "merge"
ss_merge_lists
rc=$?
case "$rc" in
0) ;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "Failed to merge API artifact with local overrides"
ss_status_mark_failure "merge_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "install"
ss_install_blocklist || {
log_error "Failed to install blocklist"
ss_status_mark_failure "install_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
}
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "restart_dnsmasq"
dnsmasq_restart
rc=$?
case "$rc" in
0)
ss_status_set health_dnsmasq_final_restart "1"
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "dnsmasq restart failed"
ss_status_set health_dnsmasq_final_restart "0"
ss_status_mark_failure "dnsmasq_restart_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "runtime_check"
check_dns_runtime
rc=$?
case "$rc" in
0)
ss_status_set health_dns_runtime "1"
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "DNS runtime check failed, restoring previous blocklist"
ss_status_set health_dns_runtime "0"
ss_status_mark_failure "dns_runtime_check_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
if ss_should_stop; then
ss_abort_refresh
return $?
fi
ss_status_set stage "blocklist_verify"
check_blocklist_applied_multi_with_stats 5 3
rc=$?
case "$rc" in
0)
log_ok "SafeShield applied successfully"
ss_status_set health_blocklist_verify "1"
ss_status_set blocklist_verification_ok "1"
ss_local_rules_state_write "$(ss_local_rules_fingerprint_from_tmp)" || true
ss_status_set_now last_local_apply
ss_status_mark_success "$refresh_interval"
rm -f "${SS_PREV_BLOCKLIST_GZ}" "${SS_INACTIVE_BLOCKLIST_FILE}"
ss_status_set blocklist_backup_available "0"
ss_refresh_lock_close
return 0
;;
130)
ss_abort_refresh
return $?
;;
*)
log_error "Blocklist verification failed, restoring previous blocklist"
ss_status_set health_blocklist_verify "0"
ss_status_set blocklist_verification_ok "0"
ss_status_mark_failure "blocklist_verification_failed"
ss_restore_and_restart
ss_refresh_lock_close
return 1
;;
esac
}
+153
View File
@@ -0,0 +1,153 @@
# shellcheck shell=ash
readonly SS_MIN_DNSMASQ_VERSION='2.93'
_ss_dnsmasq_check_error=''
ss_dnsmasq_check_error() {
printf '%s\n' "${_ss_dnsmasq_check_error}"
}
ss_dnsmasq_version() {
dnsmasq --version 2>/dev/null | awk '
NR == 1 && $1 == "Dnsmasq" && $2 == "version" {
version = $3
sub(/[^0-9.].*$/, "", version)
print version
exit
}
'
}
ss_dnsmasq_safeshield_block_supported() {
dnsmasq --test --conf-file=/dev/null --safeshield-block=/safeshield.invalid/ >/dev/null 2>&1
}
ss_require_supported_dnsmasq() {
local current_version
_ss_dnsmasq_check_error=''
ss_status_set dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION}"
if ! check_dnsmasq_binary; then
_ss_dnsmasq_check_error='dnsmasq_binary_not_found'
ss_status_set dnsmasq_version ''
ss_status_set health_dnsmasq_binary '0'
ss_status_set health_dnsmasq_version '0'
ss_status_set health_dnsmasq_features '0'
log_error 'dnsmasq binary not found'
return 1
fi
ss_status_set health_dnsmasq_binary '1'
current_version="$(ss_dnsmasq_version)"
ss_status_set dnsmasq_version "${current_version}"
if [ -z "${current_version}" ]; then
_ss_dnsmasq_check_error='dnsmasq_version_unknown'
ss_status_set health_dnsmasq_version '0'
ss_status_set health_dnsmasq_features '0'
log_error 'Unable to determine dnsmasq version'
return 1
fi
if ! is_greater_equal "${current_version}" "${SS_MIN_DNSMASQ_VERSION}"; then
_ss_dnsmasq_check_error='dnsmasq_version_unsupported'
ss_status_set health_dnsmasq_version '0'
ss_status_set health_dnsmasq_features '0'
log_error "Unsupported dnsmasq version ${current_version}; SafeShield requires ${SS_MIN_DNSMASQ_VERSION} or later"
return 1
fi
ss_status_set health_dnsmasq_version '1'
if ss_dnsmasq_safeshield_block_supported; then
ss_status_set health_dnsmasq_features '1'
else
# The SafeShield dnsmasq patch is optional for basic DNS blocking. Keep
# protection available with legacy address=/domain/# rules, while the
# statistics runtime disables itself because cumulative counters are absent.
ss_status_set health_dnsmasq_features '0'
fi
return 0
}
ss_mkdirs() {
mkdir -p "${SS_TMP_DIR}" "${SS_DNSMASQ_DIR}" "${SS_STATISTICS_DIR}"
}
ss_clean_tmp() {
rm -f \
"${SS_TMP_DIR}"/*.txt \
"${SS_TMP_DIR}"/*.raw \
"${SS_TMP_DIR}"/*.filtered \
"${SS_TMP_DIR}"/*.domains \
"${SS_TMP_DIR}"/*.json \
"${SS_RESOLVED_SOURCES}" \
"${SS_ARTIFACT_CACHE_STATE}" \
"${SS_RUNTIME_OUT}" \
2>/dev/null
}
ss_check_dnsmasq_confdir() {
local confdir
confdir="$(uci -q get dhcp.@dnsmasq[0].confdir 2>/dev/null)"
[ "$confdir" = "${SS_DNSMASQ_DIR}" ]
}
check_dnsmasq_binary() {
command_exists dnsmasq
}
check_dnsmasq_process() {
pidof dnsmasq >/dev/null 2>&1
}
check_dns_runtime() {
local domain i
check_dnsmasq_process || return 1
for domain in google.com cloudflare.com microsoft.com; do
for i in 1 2 3 4 5 6 7 8 9 10; do
ss_should_stop && return 130
if nslookup "$domain" 127.0.0.1 >"${SS_RUNTIME_OUT}" 2>/dev/null; then
if ! grep -Eq '^Address: *(0\.0\.0\.0|::)$' "${SS_RUNTIME_OUT}"; then
rm -f "${SS_RUNTIME_OUT}"
return 0
fi
fi
ss_should_stop && return 130
sleep 1
done
done
rm -f "${SS_RUNTIME_OUT}"
return 1
}
dnsmasq_restart() {
local i
log_info "Restarting dnsmasq"
/etc/init.d/dnsmasq restart >/dev/null 2>&1 || return 1
# shellcheck disable=SC2034
for i in 1 2 3 4 5 6 7 8 9 10 \
11 12 13 14 15 16 17 18 19 20 \
21 22 23 24 25 26 27 28 29 30; do
ss_should_stop && return 130
if check_dnsmasq_process && nslookup localhost 127.0.0.1 >/dev/null 2>&1; then
log_ok "dnsmasq restart done"
return 0
fi
sleep 1
done
log_error "dnsmasq did not become ready after restart"
return 1
}
+532
View File
@@ -0,0 +1,532 @@
# shellcheck shell=ash
SS_IDENTITY_DIR="${SS_IDENTITY_DIR:-/etc/safeshield}"
SS_IDENTITY_FILE="${SS_IDENTITY_FILE:-${SS_IDENTITY_DIR}/identity.env}"
SS_IDENTITY_VERSION="1"
SS_PHYSICAL_FINGERPRINT=""
SS_FINGERPRINT_VERSION="1"
SS_IDENTITY_PROVIDER=""
SS_IDENTITY_SOURCE=""
SS_IDENTITY_STRENGTH=""
SS_IDENTITY_PROFILE=""
SS_IDENTITY_VALUE_SHA256=""
SS_INSTALLATION_ID=""
SS_INSTALLATION_SECRET=""
SS_IDENTITY_CREATED_AT=""
SS_IDENTITY_UPDATED_AT=""
SS_DEVICE_CODE=""
SS_DEVICE_CODE_SOURCE="unknown"
SS_SMARTSAFEHUB_FIRMWARE_JSON="${SS_SMARTSAFEHUB_FIRMWARE_JSON:-/usr/share/smartsafehub/firmware.json}"
ss_identity_log_warn() {
if command -v log_warn >/dev/null 2>&1; then
log_warn "$1"
else
logger -t safeshield "$1" 2>/dev/null || true
fi
}
ss_identity_log_info() {
if command -v log_info >/dev/null 2>&1; then
log_info "$1"
else
logger -t safeshield "$1" 2>/dev/null || true
fi
}
ss_identity_command_exists() {
command -v "$1" >/dev/null 2>&1
}
ss_identity_sha256() {
local value="$1"
if ! ss_identity_command_exists sha256sum; then
ss_identity_log_warn "sha256sum is required for identity hashing"
return 1
fi
printf '%s' "$value" | sha256sum | awk '{print $1}'
}
ss_identity_random_hex_32() {
if [ -r /dev/urandom ] && ss_identity_command_exists hexdump; then
hexdump -n 32 -e '32/1 "%02x"' /dev/urandom 2>/dev/null && return 0
fi
ss_identity_sha256 "fallback-random|$(date +%s)|$$|$(cat /proc/uptime 2>/dev/null)"
}
ss_identity_uuid() {
local value=""
value="$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '\r\n' || true)"
if [ -n "$value" ]; then
printf '%s' "$value"
return 0
fi
value="$(ss_identity_random_hex_32)"
printf '%s-%s-%s-%s-%s' \
"$(printf '%s' "$value" | cut -c 1-8)" \
"$(printf '%s' "$value" | cut -c 9-12)" \
"$(printf '%s' "$value" | cut -c 13-16)" \
"$(printf '%s' "$value" | cut -c 17-20)" \
"$(printf '%s' "$value" | cut -c 21-32)"
}
ss_identity_read_file_one_line() {
local path="$1"
[ -r "$path" ] || return 1
tr -d '\000\r\n' <"$path" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | head -c 256
}
ss_identity_normalize_mac() {
printf '%s' "$1" |
tr 'A-F' 'a-f' |
sed -n 's/.*\([0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]\).*/\1/p' |
head -n 1
}
ss_identity_valid_mac() {
local mac="$1"
case "$mac" in
'' | 00:00:00:00:00:00 | ff:ff:ff:ff:ff:ff)
return 1
;;
esac
printf '%s' "$mac" | grep -Eq '^[0-9a-f]{2}(:[0-9a-f]{2}){5}$'
}
ss_identity_valid_sha256() {
printf '%s' "$1" | grep -Eq '^[0-9a-f]{64}$'
}
ss_identity_board_name() {
local value=""
value="$(ss_identity_read_file_one_line /tmp/sysinfo/board_name 2>/dev/null || true)"
[ -n "$value" ] || value="$(ubus call system board 2>/dev/null | jsonfilter -e '@.board_name' 2>/dev/null | head -n 1 || true)"
[ -n "$value" ] || value="unknown"
printf '%s' "$value"
}
ss_identity_valid_device_code() {
printf '%s' "$1" | grep -Eq '^[a-z0-9][a-z0-9._-]{0,63}$'
}
ss_identity_device_code_from_firmware() {
local firmware_json="${SS_SMARTSAFEHUB_FIRMWARE_JSON:-/usr/share/smartsafehub/firmware.json}"
local device_code=""
[ -r "$firmware_json" ] || return 1
ss_identity_command_exists jsonfilter || return 1
device_code="$(jsonfilter -i "$firmware_json" -e '@.device_code' 2>/dev/null | head -n 1 | tr -d '\r\n' || true)"
ss_identity_valid_device_code "$device_code" || return 1
printf '%s' "$device_code"
}
ss_identity_device_code_from_board() {
local board="$1"
case "$board" in
iptime,ax3000sm)
printf '%s' 'iptime-ax3000sm'
;;
glinet,gl-mt300n-v2 | gl.inet,gl-mt300n-v2)
printf '%s' 'gl-mt300n-v2'
;;
xiaomi,mi-router-ax3000t | xiaomi,mi-router-ax3000t-ubootmod)
printf '%s' 'xiaomi-ax3000t'
;;
*)
return 1
;;
esac
}
ss_identity_refresh_device_code() {
local device_code=""
local board=""
device_code="$(ss_identity_device_code_from_firmware 2>/dev/null || true)"
if [ -n "$device_code" ]; then
SS_DEVICE_CODE="$device_code"
SS_DEVICE_CODE_SOURCE="smartsafehub_firmware"
return 0
fi
board="$(ss_identity_board_name)"
device_code="$(ss_identity_device_code_from_board "$board" 2>/dev/null || true)"
if [ -n "$device_code" ]; then
SS_DEVICE_CODE="$device_code"
SS_DEVICE_CODE_SOURCE="board"
return 0
fi
SS_DEVICE_CODE=""
SS_DEVICE_CODE_SOURCE="unknown"
return 0
}
ss_identity_profile_code() {
local board="$1"
local profile=""
case "$board" in
iptime,ax3000sm | *ax3000sm*)
profile="iptime_ax3000sm"
;;
glinet,gl-mt300n-v2 | gl.inet,gl-mt300n-v2 | *gl-mt300n-v2*)
profile="gl_mt300n_v2"
;;
smartsafehub,* | *smartsafehub*)
profile="smartsafehub"
;;
*)
profile="$(printf '%s' "$board" | tr 'A-Z,.-' 'a-z___' | sed 's/[^a-z0-9_]/_/g')"
[ -n "$profile" ] || profile="unknown"
;;
esac
printf '%s' "$profile"
}
ss_identity_mtd_device_by_name() {
local name="$1"
[ -r /proc/mtd ] || return 1
awk -F: -v q="\"${name}\"" 'index($0, q) { print "/dev/" $1; exit }' /proc/mtd
}
ss_identity_hex_to_mac() {
local hex="$1"
printf '%s' "$hex" | sed 's/[^0-9a-fA-F]//g' | tr 'A-F' 'a-f' | sed 's/../&:/g; s/:$//'
}
ss_identity_mtd_get_mac_binary_fallback() {
local part="$1"
local offset="$2"
local dev=""
local hex=""
dev="$(ss_identity_mtd_device_by_name "$part" 2>/dev/null || true)"
[ -n "$dev" ] || return 1
[ -r "$dev" ] || return 1
ss_identity_command_exists dd || return 1
ss_identity_command_exists hexdump || return 1
# ash supports hexadecimal arithmetic, so offsets like 0x4 are accepted.
hex="$(dd if="$dev" bs=1 skip=$((offset)) count=6 2>/dev/null | hexdump -v -e '1/1 "%02x"' 2>/dev/null)"
[ "${#hex}" -eq 12 ] || return 1
ss_identity_hex_to_mac "$hex"
}
ss_identity_mtd_get_mac() {
local part="$1"
local offset="$2"
local mac=""
if ss_identity_command_exists mtd_get_mac_binary; then
mac="$(mtd_get_mac_binary "$part" "$offset" 2>/dev/null || true)"
mac="$(ss_identity_normalize_mac "$mac")"
if ss_identity_valid_mac "$mac"; then
printf '%s' "$mac"
return 0
fi
fi
mac="$(ss_identity_mtd_get_mac_binary_fallback "$part" "$offset" 2>/dev/null || true)"
mac="$(ss_identity_normalize_mac "$mac")"
ss_identity_valid_mac "$mac" || return 1
printf '%s' "$mac"
}
ss_identity_try_mtd_factory_mac() {
local board="$1"
local mac=""
local offset=""
case "$board" in
iptime,ax3000sm | *ax3000sm*)
mac="$(ss_identity_mtd_get_mac Factory 0x4 2>/dev/null || true)"
if ss_identity_valid_mac "$mac"; then
printf '%s|%s' "$mac" "mtd:Factory:0x4:mac"
return 0
fi
mac="$(ss_identity_mtd_get_mac factory 0x4 2>/dev/null || true)"
if ss_identity_valid_mac "$mac"; then
printf '%s|%s' "$mac" "mtd:factory:0x4:mac"
return 0
fi
;;
glinet,gl-mt300n-v2 | gl.inet,gl-mt300n-v2 | *gl-mt300n-v2*)
# Must be verified on the real GL-MT300N-V2 before production.
for offset in 0x4 0x28 0x2e; do
mac="$(ss_identity_mtd_get_mac factory "$offset" 2>/dev/null || true)"
if ss_identity_valid_mac "$mac"; then
printf '%s|%s' "$mac" "mtd:factory:${offset}:mac"
return 0
fi
done
;;
esac
return 1
}
ss_identity_try_factory_device_id() {
local value=""
local path=""
# Future SmartSafeHub hardware can expose a factory-programmed device_id
# through DTS/NVMEM, sysfs, or a small platform driver.
for path in \
/sys/firmware/smartsafehub/device_id \
/sys/firmware/devicetree/base/smartsafehub,device-id \
/proc/device-tree/smartsafehub,device-id; do
value="$(ss_identity_read_file_one_line "$path" 2>/dev/null || true)"
case "$value" in
'' | *[!A-Za-z0-9._:-]*) ;;
*)
printf '%s|%s' "$value" "path:${path}"
return 0
;;
esac
done
return 1
}
ss_identity_detect_primary_mac() {
local iface=""
local mac=""
for iface in br-lan eth0 eth1 wan lan wlan0 wlan1; do
mac="$(cat "/sys/class/net/${iface}/address" 2>/dev/null || true)"
mac="$(ss_identity_normalize_mac "$mac")"
if ss_identity_valid_mac "$mac"; then
printf '%s' "$mac"
return 0
fi
done
if ss_identity_command_exists ip; then
mac="$(ip link 2>/dev/null | awk '/link\/ether/ { print $2; exit }')"
mac="$(ss_identity_normalize_mac "$mac")"
if ss_identity_valid_mac "$mac"; then
printf '%s' "$mac"
return 0
fi
fi
return 1
}
ss_identity_compute_physical() {
local model="$1"
local arch="$2"
local board=""
local profile=""
local value=""
local source=""
local result=""
local material=""
board="$(ss_identity_board_name)"
profile="$(ss_identity_profile_code "$board")"
SS_IDENTITY_PROFILE="$profile"
# SmartSafeHub 전용 기기는 factory-programmed device_id를 우선 사용
case "$profile" in
smartsafehub*)
result="$(ss_identity_try_factory_device_id 2>/dev/null || true)"
if [ -n "$result" ]; then
value="${result%%|*}"
source="${result#*|}"
SS_IDENTITY_PROVIDER="factory_device_id"
SS_IDENTITY_SOURCE="$source"
SS_IDENTITY_STRENGTH="hardware_strong"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
material="safeshield-physical-v1|provider=${SS_IDENTITY_PROVIDER}|source=${SS_IDENTITY_SOURCE}|profile=${profile}|value_sha256=${SS_IDENTITY_VALUE_SHA256}"
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "$material")" || return 1
return 0
fi
;;
esac
# 기존 판매 공유기는 factory/ART/Factory 파티션 MAC을 우선 사용
result="$(ss_identity_try_mtd_factory_mac "$board" 2>/dev/null || true)"
if [ -n "$result" ]; then
value="${result%%|*}"
source="${result#*|}"
SS_IDENTITY_PROVIDER="factory_mac"
SS_IDENTITY_SOURCE="$source"
SS_IDENTITY_STRENGTH="hardware_soft"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
material="safeshield-physical-v1|provider=${SS_IDENTITY_PROVIDER}|source=${SS_IDENTITY_SOURCE}|profile=${profile}|mac_sha256=${SS_IDENTITY_VALUE_SHA256}"
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "$material")" || return 1
return 0
fi
# Last hardware-derived fallback. This is not fully reinstall-stable if the
# user changes MAC clone settings, but it is better than failing completely.
value="$(ss_identity_detect_primary_mac 2>/dev/null || true)"
if [ -n "$value" ]; then
SS_IDENTITY_PROVIDER="interface_mac"
SS_IDENTITY_SOURCE="sysfs:/sys/class/net/*/address"
SS_IDENTITY_STRENGTH="hardware_soft_unverified"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
material="safeshield-physical-v1|provider=${SS_IDENTITY_PROVIDER}|source=${SS_IDENTITY_SOURCE}|profile=${profile}|model=${model}|arch=${arch}|mac_sha256=${SS_IDENTITY_VALUE_SHA256}"
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "$material")" || return 1
return 0
fi
return 1
}
ss_identity_load() {
[ -r "$SS_IDENTITY_FILE" ] || return 1
# shellcheck disable=SC1090
. "$SS_IDENTITY_FILE"
SS_IDENTITY_VERSION="${IDENTITY_VERSION:-}"
SS_FINGERPRINT_VERSION="${FINGERPRINT_VERSION:-}"
SS_PHYSICAL_FINGERPRINT="${PHYSICAL_FINGERPRINT:-}"
SS_IDENTITY_PROVIDER="${IDENTITY_PROVIDER:-}"
SS_IDENTITY_SOURCE="${IDENTITY_SOURCE:-}"
SS_IDENTITY_STRENGTH="${IDENTITY_STRENGTH:-}"
SS_IDENTITY_PROFILE="${IDENTITY_PROFILE:-}"
SS_IDENTITY_VALUE_SHA256="${IDENTITY_VALUE_SHA256:-}"
SS_INSTALLATION_ID="${INSTALLATION_ID:-}"
SS_INSTALLATION_SECRET="${INSTALLATION_SECRET:-}"
SS_IDENTITY_CREATED_AT="${CREATED_AT:-}"
SS_IDENTITY_UPDATED_AT="${UPDATED_AT:-}"
ss_identity_valid_sha256 "$SS_PHYSICAL_FINGERPRINT" || return 1
[ -n "$SS_INSTALLATION_ID" ] || return 1
[ -n "$SS_IDENTITY_PROVIDER" ] || return 1
[ -n "$SS_IDENTITY_SOURCE" ] || return 1
[ -n "$SS_IDENTITY_STRENGTH" ] || return 1
[ -n "$SS_IDENTITY_PROFILE" ] || return 1
return 0
}
ss_identity_write_env() {
local tmp="${SS_IDENTITY_FILE}.tmp"
mkdir -p "$SS_IDENTITY_DIR" || return 1
chmod 700 "$SS_IDENTITY_DIR" 2>/dev/null || true
cat >"$tmp" <<EOF_ENV
IDENTITY_VERSION='${SS_IDENTITY_VERSION}'
FINGERPRINT_VERSION='${SS_FINGERPRINT_VERSION}'
PHYSICAL_FINGERPRINT='${SS_PHYSICAL_FINGERPRINT}'
IDENTITY_PROVIDER='${SS_IDENTITY_PROVIDER}'
IDENTITY_SOURCE='${SS_IDENTITY_SOURCE}'
IDENTITY_STRENGTH='${SS_IDENTITY_STRENGTH}'
IDENTITY_PROFILE='${SS_IDENTITY_PROFILE}'
IDENTITY_VALUE_SHA256='${SS_IDENTITY_VALUE_SHA256}'
INSTALLATION_ID='${SS_INSTALLATION_ID}'
INSTALLATION_SECRET='${SS_INSTALLATION_SECRET}'
CREATED_AT='${SS_IDENTITY_CREATED_AT}'
UPDATED_AT='${SS_IDENTITY_UPDATED_AT}'
EOF_ENV
chmod 600 "$tmp" 2>/dev/null || true
mv -f "$tmp" "$SS_IDENTITY_FILE"
}
ss_identity_sync_uci() {
uci -q batch <<EOF_UCI
set safeshield.identity=identity
set safeshield.identity.physical_fingerprint='${SS_PHYSICAL_FINGERPRINT}'
set safeshield.identity.fingerprint_version='${SS_FINGERPRINT_VERSION}'
set safeshield.identity.identity_provider='${SS_IDENTITY_PROVIDER}'
set safeshield.identity.identity_source='${SS_IDENTITY_SOURCE}'
set safeshield.identity.identity_strength='${SS_IDENTITY_STRENGTH}'
set safeshield.identity.identity_profile='${SS_IDENTITY_PROFILE}'
set safeshield.identity.device_code='${SS_DEVICE_CODE}'
set safeshield.identity.device_code_source='${SS_DEVICE_CODE_SOURCE}'
set safeshield.identity.installation_id='${SS_INSTALLATION_ID}'
set safeshield.identity.created_at='${SS_IDENTITY_CREATED_AT}'
set safeshield.identity.updated_at='${SS_IDENTITY_UPDATED_AT}'
commit safeshield
EOF_UCI
}
ss_identity_status_set() {
command -v ss_status_set >/dev/null 2>&1 || return 0
ss_status_set physical_fingerprint "$SS_PHYSICAL_FINGERPRINT"
ss_status_set fingerprint_version "$SS_FINGERPRINT_VERSION"
ss_status_set identity_provider "$SS_IDENTITY_PROVIDER"
ss_status_set identity_source "$SS_IDENTITY_SOURCE"
ss_status_set identity_strength "$SS_IDENTITY_STRENGTH"
ss_status_set identity_profile "$SS_IDENTITY_PROFILE"
ss_status_set device_code "$SS_DEVICE_CODE"
ss_status_set device_code_source "$SS_DEVICE_CODE_SOURCE"
ss_status_set installation_id "$SS_INSTALLATION_ID"
}
ss_identity_create() {
local model="$1"
local arch="$2"
local now=""
local value=""
now="$(date +%s)"
SS_FINGERPRINT_VERSION="1"
SS_INSTALLATION_ID="$(ss_identity_uuid)"
SS_INSTALLATION_SECRET="$(ss_identity_random_hex_32)"
SS_IDENTITY_CREATED_AT="$now"
SS_IDENTITY_UPDATED_AT="$now"
if ! ss_identity_compute_physical "$model" "$arch" || ! ss_identity_valid_sha256 "$SS_PHYSICAL_FINGERPRINT"; then
# Final fallback only when no physical source exists. This preserves
# basic operation but cannot survive full reinstall/factory reset.
value="$(ss_identity_random_hex_32)"
SS_IDENTITY_PROVIDER="installation_random"
SS_IDENTITY_SOURCE="${SS_IDENTITY_FILE}"
SS_IDENTITY_STRENGTH="fallback_random"
SS_IDENTITY_PROFILE="$(ss_identity_profile_code "$(ss_identity_board_name)")"
SS_IDENTITY_VALUE_SHA256="$(ss_identity_sha256 "$value")" || return 1
SS_PHYSICAL_FINGERPRINT="$(ss_identity_sha256 "safeshield-physical-v1|provider=installation_random|profile=${SS_IDENTITY_PROFILE}|value_sha256=${SS_IDENTITY_VALUE_SHA256}")" || return 1
ss_identity_log_warn "No stable hardware identity source found; using installation-random physical_fingerprint"
fi
ss_identity_refresh_device_code
ss_identity_write_env || return 1
ss_identity_sync_uci || true
ss_identity_status_set || true
ss_identity_log_info "SafeShield identity initialized provider=${SS_IDENTITY_PROVIDER} source=${SS_IDENTITY_SOURCE}"
}
ss_identity_ensure() {
local model="${1:-}"
local arch="${2:-}"
if ss_identity_load; then
ss_identity_refresh_device_code
ss_identity_sync_uci || true
ss_identity_status_set || true
return 0
fi
ss_identity_create "$model" "$arch"
}
+63
View File
@@ -0,0 +1,63 @@
# shellcheck shell=ash
readonly __DOT__='[.]'
readonly __OK__='\033[0;32m[\xe2\x9c\x93]\033[0m'
readonly _ERROR_='\033[0;31m[ERROR]\033[0m'
readonly _WARNING_='\033[0;33m[WARN]\033[0m'
log_line() {
[ -z "$ss_verbosity" ] && ss_verbosity="$(uci_get "$PKG_NAME" 'config' 'verbosity' '1')"
if [ "$#" -gt 1 ]; then
case "$1" in
[0-9])
[ $((ss_verbosity & $1)) -gt 0 ] || return 0
shift
;;
esac
fi
local msg="$*"
local queue="/dev/shm/${PKG_NAME}-output-$$"
if [ -z "$_out_is_tty" ]; then
if [ -t 1 ]; then
_out_is_tty=1
else
_out_is_tty=0
fi
fi
[ "$_out_is_tty" -eq 1 ] && printf '%b' "$msg"
case "$msg" in
*\\n*)
if [ -s "$queue" ]; then
msg="$(cat "$queue")$msg"
rm -f "$queue"
fi
msg="$(printf '%b' "$msg" | sed 's/\x1b\[[0-9;]*m//g')"
logger -t "$PKG_NAME [$$]" -- "$msg"
;;
*)
printf '%b' "$msg" >>"$queue"
;;
esac
}
log_info() {
log_line 1 "${__DOT__} $*\n"
}
log_ok() {
log_line 1 "${__OK__} $*\n"
}
log_warn() {
log_line 1 "${_WARNING_} $*\n"
}
log_error() {
log_line 1 "${_ERROR_} $*\n"
}
@@ -0,0 +1,398 @@
# shellcheck shell=ash
# Paths are defined by core.sh before these helpers are called.
# shellcheck disable=SC2154
SS_STATISTICS_POLL_COMMAND="${SS_STATISTICS_POLL_COMMAND:-/usr/libexec/safeshield-stats-poll}"
SS_STATISTICS_REBASELINE_FILE="${SS_STATISTICS_REBASELINE_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/rebaseline}"
ss_statistics_profile_code() {
local board=""
local profile=""
if command -v ss_identity_board_name >/dev/null 2>&1 && command -v ss_identity_profile_code >/dev/null 2>&1; then
board="$(ss_identity_board_name 2>/dev/null || true)"
if [ -n "$board" ]; then
profile="$(ss_identity_profile_code "$board" 2>/dev/null || true)"
fi
fi
case "$profile" in
'' | unknown)
[ -n "${SS_IDENTITY_PROFILE:-}" ] && profile="${SS_IDENTITY_PROFILE}"
;;
esac
[ -n "$profile" ] || profile="unknown"
printf '%s\n' "$profile"
}
ss_statistics_effective_snapshot_interval() {
local configured="${1:-60}"
if [ "$configured" = "60" ] && [ "$(ss_statistics_profile_code)" = "gl_mt300n_v2" ]; then
printf '%s\n' '300'
return 0
fi
printf '%s\n' "$configured"
}
ss_statistics_persistence_enabled() {
case "$(ss_statistics_profile_code)" in
gl_mt300n_v2)
return 1
;;
*)
return 0
;;
esac
}
ss_statistics_cleanup_legacy_dnsmasq_logging() {
if [ -f "${SS_STATISTICS_DNSMASQ_CONF}" ]; then
rm -f "${SS_STATISTICS_DNSMASQ_CONF}" || return 1
printf '%s\n' '1'
else
printf '%s\n' '0'
fi
}
# Kept as an internal compatibility wrapper for the init script. Statistics no
# longer enables dnsmasq query logging; this only removes a pre-0.3.20-r2 file.
ss_statistics_configure_dnsmasq() {
ss_statistics_cleanup_legacy_dnsmasq_logging
}
ss_statistics_source_available() {
[ -x "$SS_STATISTICS_POLL_COMMAND" ] || return 1
"$SS_STATISTICS_POLL_COMMAND" >/dev/null 2>&1
}
ss_statistics_request_rebaseline() {
mkdir -p "${SS_STATISTICS_REBASELINE_FILE%/*}" || return 1
: >"${SS_STATISTICS_REBASELINE_FILE}" || return 1
}
ss_statistics_disable_if_dnsmasq_unpatched() {
[ "${ss_enabled}" = "1" ] || return 0
[ "${ss_statistics_enabled}" = "1" ] || return 0
ss_dnsmasq_safeshield_block_supported && return 0
log_warn 'Disabling SafeShield statistics at runtime because dnsmasq does not provide the SafeShield extension'
ss_status_set health_dnsmasq_features '0'
ss_status_add_warning 'statistics_disabled_dnsmasq_safeshield_patch_unavailable'
# Keep the configured preference intact. A later firmware upgrade that adds
# the dnsmasq extension can therefore restore statistics automatically.
ss_statistics_enabled='0'
return 0
}
ss_statistics_add_procd_instance() {
procd_open_instance statistics
procd_set_param command /usr/libexec/safeshield-statsd
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param file /etc/config/safeshield
procd_set_param data service safeshield-statistics
procd_close_instance
}
ss_statistics_add_upload_procd_instance() {
procd_open_instance statistics-upload
procd_set_param command /usr/libexec/safeshield-statistics-uploader
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param file /etc/config/safeshield
procd_set_param data service safeshield-statistics-upload
procd_close_instance
}
ss_statistics_reconcile_runtime() {
local legacy_dnsmasq_changed=0
if [ "${ss_enabled}" = "1" ] && [ "${ss_statistics_enabled}" = "1" ]; then
ss_require_supported_dnsmasq || {
log_error "Failed dnsmasq compatibility check while enabling statistics"
return 1
}
ss_statistics_disable_if_dnsmasq_unpatched || return 1
fi
if [ "${ss_enabled}" != "1" ] || [ "${ss_statistics_enabled}" != "1" ]; then
procd_kill "${PKG_NAME}" statistics >/dev/null 2>&1 || true
procd_kill "${PKG_NAME}" statistics-upload >/dev/null 2>&1 || true
ss_statistics_request_rebaseline || {
log_error "Failed to mark statistics for rebaseline while statistics are inactive"
return 1
}
legacy_dnsmasq_changed="$(ss_statistics_cleanup_legacy_dnsmasq_logging)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
if [ "$legacy_dnsmasq_changed" = "1" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
return 0
fi
legacy_dnsmasq_changed="$(ss_statistics_cleanup_legacy_dnsmasq_logging)" || {
log_error "Failed to remove legacy dnsmasq statistics logging"
return 1
}
if [ "$legacy_dnsmasq_changed" = "1" ]; then
dnsmasq_restart || {
log_error "Failed to restart dnsmasq after removing legacy statistics logging"
return 1
}
fi
ss_statistics_source_available || {
log_error "dnsmasq SafeShield statistics UBus source is unavailable"
return 1
}
procd_open_service "${PKG_NAME}"
ss_statistics_add_procd_instance
ss_statistics_add_upload_procd_instance
procd_close_service add
}
ss_statistics_upload_credentials_path() {
printf '%s\n' "${SS_STATISTICS_UPLOAD_CREDENTIALS_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.credentials}"
}
ss_statistics_upload_entitlement_path() {
printf '%s\n' "${SS_STATISTICS_UPLOAD_ENTITLEMENT_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.entitlement}"
}
ss_statistics_set_upload_entitlement() {
local state="$1"
local path tmp old_umask checked_at
case "$state" in
allowed | denied) ;;
unknown)
rm -f "$(ss_statistics_upload_entitlement_path)"
return 0
;;
*) return 1 ;;
esac
checked_at="$(date +%s 2>/dev/null || printf '0')"
is_valid_integer "$checked_at" || checked_at=0
path="$(ss_statistics_upload_entitlement_path)"
tmp="${path}.tmp.$$"
mkdir -p "${path%/*}" || return 1
old_umask="$(umask)"
umask 077
printf '%s\n%s\n' "$state" "$checked_at" >"$tmp" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
mv -f "$tmp" "$path" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
umask "$old_umask"
}
ss_statistics_upload_entitlement() {
local state
state="$(sed -n '1p' "$(ss_statistics_upload_entitlement_path)" 2>/dev/null)"
case "$state" in
allowed | denied) printf '%s\n' "$state" ;;
*) printf '%s\n' 'unknown' ;;
esac
}
ss_statistics_upload_denied() {
[ "$(ss_statistics_upload_entitlement)" = 'denied' ]
}
ss_statistics_upload_denied_recheck_due() {
local interval_s="${1:-43200}"
local checked_at now elapsed
ss_statistics_upload_denied || return 1
is_valid_integer "$interval_s" || interval_s=43200
[ "$interval_s" -ge 60 ] 2>/dev/null || interval_s=43200
checked_at="$(sed -n '2p' "$(ss_statistics_upload_entitlement_path)" 2>/dev/null)"
is_valid_integer "$checked_at" || return 0
[ "$checked_at" -gt 0 ] 2>/dev/null || return 0
now="$(date +%s 2>/dev/null || printf '0')"
is_valid_integer "$now" || return 0
[ "$now" -gt 0 ] 2>/dev/null || return 0
# A backward wall-clock correction must not postpone entitlement recovery.
[ "$now" -lt "$checked_at" ] 2>/dev/null && return 0
elapsed=$((now - checked_at))
[ "$elapsed" -ge "$interval_s" ]
}
ss_statistics_clear_upload_credentials() {
rm -f "$(ss_statistics_upload_credentials_path)"
SS_STATISTICS_UPLOAD_URL=''
SS_STATISTICS_UPLOAD_TOKEN=''
SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S='0'
}
ss_statistics_disable_cloud_upload() {
ss_statistics_clear_upload_credentials
rm -f \
"${SS_STATISTICS_UPLOAD_PENDING_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.pending.json}" \
"${SS_STATISTICS_UPLOAD_PENDING_META_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.pending.meta}" \
"${SS_STATISTICS_UPLOAD_STATE_FILE:-${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload.state}"
ss_statistics_set_upload_entitlement denied
}
ss_statistics_store_upload_credentials() {
local response="$1"
local path tmp url token expires_in old_umask
[ -s "$response" ] || return 1
url="$(ss_json_get_file "$response" '@.statistics.upload_url')"
token="$(ss_json_get_file "$response" '@.statistics.token')"
expires_in="$(ss_json_get_file "$response" '@.statistics.token_expires_in_s')"
case "$url" in
https://www.smartsafehub.com/api/v1/statistics | https://www.smartsafehub.com/api/v1/statistics/) ;;
*)
return 1
;;
esac
[ -n "$token" ] || return 1
[ "${#token}" -le 4096 ] 2>/dev/null || return 1
if printf '%s' "$token" | grep -q '[[:space:]]'; then
return 1
fi
is_valid_integer "$expires_in" || expires_in='0'
path="$(ss_statistics_upload_credentials_path)"
tmp="${path}.tmp.$$"
mkdir -p "${path%/*}" || return 1
old_umask="$(umask)"
umask 077
printf '%s\n%s\n%s\n' "$url" "$token" "$expires_in" >"$tmp" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
chmod 600 "$tmp" 2>/dev/null || true
mv -f "$tmp" "$path" || {
umask "$old_umask"
rm -f "$tmp"
return 1
}
umask "$old_umask"
ss_statistics_set_upload_entitlement allowed || true
return 0
}
ss_statistics_sync_upload_entitlement() {
local response="$1"
local url token
[ -s "$response" ] || return 1
url="$(ss_json_get_file "$response" '@.statistics.upload_url')"
token="$(ss_json_get_file "$response" '@.statistics.token')"
if [ -z "$url" ] && [ -z "$token" ]; then
ss_statistics_disable_cloud_upload || return 1
return 2
fi
if [ -z "$url" ] || [ -z "$token" ]; then
ss_statistics_clear_upload_credentials
ss_statistics_set_upload_entitlement unknown || true
return 1
fi
ss_statistics_store_upload_credentials "$response"
}
ss_statistics_load_upload_credentials() {
local path
path="$(ss_statistics_upload_credentials_path)"
SS_STATISTICS_UPLOAD_URL=''
SS_STATISTICS_UPLOAD_TOKEN=''
SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S='0'
ss_statistics_upload_denied && {
ss_statistics_clear_upload_credentials
return 1
}
[ -s "$path" ] || return 1
SS_STATISTICS_UPLOAD_URL="$(sed -n '1p' "$path" 2>/dev/null)"
SS_STATISTICS_UPLOAD_TOKEN="$(sed -n '2p' "$path" 2>/dev/null)"
SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S="$(sed -n '3p' "$path" 2>/dev/null)"
case "$SS_STATISTICS_UPLOAD_URL" in
https://www.smartsafehub.com/api/v1/statistics | https://www.smartsafehub.com/api/v1/statistics/) ;;
*)
ss_statistics_clear_upload_credentials
return 1
;;
esac
[ -n "$SS_STATISTICS_UPLOAD_TOKEN" ] || {
ss_statistics_clear_upload_credentials
return 1
}
is_valid_integer "$SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S" || SS_STATISTICS_UPLOAD_TOKEN_EXPIRES_IN_S='0'
return 0
}
ss_statistics_refresh_upload_credentials() {
local request response resolve_url retries rc=1
resolve_url='https://www.smartsafehub.com/api/v1/licenses/resolve'
request="${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload-resolve-request.json"
response="${SS_STATISTICS_DIR:-/tmp/safeshield/statistics}/upload-resolve-response.json"
ss_load_config || return 1
mkdir -p "${request%/*}" || return 1
ss_refresh_lock_open_wait "${ss_download_timeout:-10}" || return $?
if ! ss_write_resolve_payload "$request"; then
ss_refresh_lock_close
return 1
fi
retries=1
while [ "$retries" -le "${ss_download_retry:-3}" ]; do
ss_should_stop && {
rc=130
break
}
if ss_http_post_json "$resolve_url" "$request" "$response" && [ -s "$response" ]; then
rc=0
ss_statistics_sync_upload_entitlement "$response" || rc=$?
break
fi
if [ "${SS_HTTP_STATUS:-}" = '426' ]; then
log_error 'Hub requires a newer SafeShield version before statistics can be uploaded'
break
fi
retries=$((retries + 1))
[ "$retries" -le "${ss_download_retry:-3}" ] && sleep 1
done
rm -f "$request" "$response" "${response}.uclient.txt"
ss_refresh_lock_close
return "$rc"
}
@@ -0,0 +1,182 @@
# SafeShield statistics: common counter and device helpers.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
# SafeShield dnsmasq statistics collector.
# Production input is a low-frequency snapshot stream generated from dnsmasq
# cumulative UBus counters. Legacy log fixtures remain accepted for regression
# coverage. Raw queried domains are never persisted.
function numeric(value, fallback) {
if (value ~ /^[0-9]+$/) {
return value + 0
}
return fallback
}
function current_time() {
if (fixed_now > 0) {
return fixed_now + (event_index * fixed_step)
}
return systime()
}
function hour_start(epoch) {
return int(epoch / 3600) * 3600
}
function device_bucket_key(key, bucket) {
return key SUBSEP bucket
}
function prune_buckets(now, cutoff, bucket, composite, parts) {
cutoff = hour_start(now) - ((retention_hours - 1) * 3600)
for (bucket in queries) {
if ((bucket + 0) < cutoff) {
delete queries[bucket]
delete blocked[bucket]
}
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
bucket = parts[2] + 0
if (bucket < cutoff) {
delete device_hour_queries[composite]
delete device_hour_blocked[composite]
}
}
}
function normalize_state_field(value, normalized) {
normalized = value
gsub(/[\t\r\n]/, "_", normalized)
return (normalized == "") ? "*" : normalized
}
function decode_state_field(value) {
return (value == "*") ? "" : value
}
function normalize_mac(value, parts, count, i, mac) {
mac = tolower(value)
count = split(mac, parts, ":")
if (count != 6 || mac == "00:00:00:00:00:00") {
return ""
}
for (i = 1; i <= count; i++) {
if (parts[i] !~ /^[0-9a-f][0-9a-f]$/) {
return ""
}
}
return mac
}
function clear_device_identity_owner(key, client_id, duid) {
client_id = device_client_id[key]
duid = device_duid[key]
if (client_id != "" && client_id_owner[client_id] == key) {
delete client_id_owner[client_id]
}
if (duid != "" && duid_owner[duid] == key) {
delete duid_owner[duid]
}
}
function remove_device_metadata(key) {
if (!(key in device_seen)) {
return
}
clear_device_identity_owner(key)
delete device_seen[key]
delete device_mac[key]
delete device_ip[key]
delete device_hostname[key]
delete device_client_id[key]
delete device_duid[key]
delete device_queries[key]
delete device_blocked[key]
device_count--
}
function remove_device(key, composite, parts) {
if (!(key in device_seen)) {
return
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
if (parts[1] == key) {
delete device_hour_queries[composite]
delete device_hour_blocked[composite]
}
}
remove_device_metadata(key)
}
function register_device(key, mac, ip, hostname, client_id, duid, selected, owner) {
if (key == "") {
return ""
}
selected = key
if (!(selected in device_seen) && device_count >= max_devices && selected != "other" && selected != "unknown") {
devices_truncated = 1
selected = "other"
mac = ""
ip = ""
hostname = "Other devices"
client_id = ""
duid = ""
}
if (!(selected in device_seen)) {
# Initialize every per-device array element together. Besides keeping the
# device record structurally complete, this avoids passing a missing awk
# associative-array element to helper functions during serialization.
device_seen[selected] = 1
device_mac[selected] = ""
device_ip[selected] = ""
device_hostname[selected] = ""
device_client_id[selected] = ""
device_duid[selected] = ""
device_queries[selected] = 0
device_blocked[selected] = 0
device_count++
}
if (selected != "other" && selected != "unknown") {
if (mac != "") {
device_mac[selected] = tolower(mac)
}
if (ip != "") {
device_ip[selected] = ip
}
if (hostname != "" && hostname != "*") {
device_hostname[selected] = hostname
}
if (client_id != "") {
owner = client_id_owner[client_id]
if (owner == "" || owner == selected) {
device_client_id[selected] = client_id
client_id_owner[client_id] = selected
}
}
if (duid != "") {
owner = duid_owner[duid]
if (owner == "" || owner == selected) {
device_duid[selected] = duid
duid_owner[duid] = selected
}
}
}
else if (selected == "other") {
device_hostname[selected] = "Other devices"
}
else {
device_hostname[selected] = "Unknown devices"
}
return selected
}
@@ -0,0 +1,450 @@
# SafeShield statistics: state and journal recovery.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function state_file_updated_at(path, line, fields, count, meta_seen, meta_updated, expected_queries, expected_blocked, bucket_queries, bucket_blocked) {
if (path == "") {
return -1
}
meta_seen = 0
meta_updated = 0
expected_queries = 0
expected_blocked = 0
bucket_queries = 0
bucket_blocked = 0
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 5 && fields[1] == "meta") {
meta_seen = 1
meta_updated = numeric(fields[3], 0)
expected_queries = numeric(fields[4], 0)
expected_blocked = numeric(fields[5], 0)
}
else if (count >= 4 && fields[1] == "bucket") {
bucket_queries += numeric(fields[3], 0)
bucket_blocked += numeric(fields[4], 0)
}
}
close(path)
if (!meta_seen) {
return -1
}
if (bucket_queries != expected_queries || bucket_blocked != expected_blocked) {
return -1
}
return meta_updated
}
function state_file_generation_id(path, line, fields, count) {
if (path == "") {
return ""
}
if ((getline line < path) <= 0) {
close(path)
return ""
}
close(path)
count = split(line, fields, "\t")
if (count < 15 || fields[1] != "meta") {
return ""
}
return decode_state_field(fields[15])
}
function state_file_snapshot_seq(path, line, fields, count, sequence) {
if (path == "") {
return -1
}
sequence = -1
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 16 && fields[1] == "meta") {
sequence = numeric(fields[16], 0)
break
}
}
close(path)
return sequence
}
function journal_file_snapshot_seq(path, line, fields, count, current_txn, current_seq, latest_seq) {
if (path == "") {
return -1
}
current_txn = ""
current_seq = 0
latest_seq = -1
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 13 && fields[1] == "begin") {
current_txn = fields[2]
current_seq = numeric(fields[13], 0)
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
latest_seq = current_seq
current_txn = ""
current_seq = 0
}
}
close(path)
return latest_seq
}
function journal_file_generation_id(path, line, fields, count, current_txn, current_generation, latest_generation) {
if (path == "") {
return ""
}
current_txn = ""
current_generation = ""
latest_generation = ""
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
current_generation = (count >= 12) ? decode_state_field(fields[12]) : ""
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
latest_generation = current_generation
current_txn = ""
current_generation = ""
}
}
close(path)
return latest_generation
}
function load_sequence_reservation(path, expected_generation, line, fields, count, file_generation, reserved) {
if (path == "" || expected_generation == "") {
return 0
}
if ((getline line < path) <= 0) {
close(path)
return 0
}
close(path)
count = split(line, fields, "\t")
if (count < 3 || fields[1] != "reserve") {
return 0
}
file_generation = decode_state_field(fields[2])
if (file_generation != expected_generation) {
return 0
}
reserved = numeric(fields[3], 0)
return (reserved > 0) ? reserved : 0
}
function load_state_file(path, line, fields, count, bucket, key, mac, ip, hostname, client_id, duid, composite) {
if (path == "") {
return 0
}
while ((getline line < path) > 0) {
count = split(line, fields, "\t")
if (count >= 5 && fields[1] == "meta") {
started_at = numeric(fields[2], 0)
updated_at = numeric(fields[3], 0)
total_queries = numeric(fields[4], 0)
total_blocked = numeric(fields[5], 0)
if (count >= 6) {
devices_truncated = numeric(fields[6], 0) ? 1 : 0
}
if (count >= 7) {
persistent_updated_at = numeric(fields[7], 0)
}
if (count >= 8) {
state_schema_version = numeric(fields[8], 1)
}
if (count >= 9) {
loaded_session_started_at = numeric(fields[9], 0)
}
if (count >= 10) {
persistence_healthy = numeric(fields[10], 1) ? 1 : 0
}
if (count >= 11) {
persistent_error_count = numeric(fields[11], 0)
}
if (count >= 12) {
persistent_last_error_at = numeric(fields[12], 0)
}
if (count >= 13) {
persistent_compacted_at = numeric(fields[13], 0)
}
if (count >= 14) {
last_journal_completed_bucket = numeric(fields[14], 0)
}
if (count >= 15) {
generation_id = decode_state_field(fields[15])
}
if (count >= 16) {
snapshot_seq = numeric(fields[16], 0)
}
if (count >= 17) {
snapshot_seq_reserved_through = numeric(fields[17], 0)
}
loaded_state = 1
}
else if (count >= 8 && fields[1] == "source") {
source_instance_id = decode_state_field(fields[2])
source_transport_scope = decode_state_field(fields[3])
source_client_capacity = numeric(fields[4], 0)
source_tracked_clients = numeric(fields[5], 0)
source_untracked_queries = numeric(fields[6], 0)
source_total_queries = numeric(fields[7], 0)
source_total_blocked = numeric(fields[8], 0)
source_available = (count >= 9 && numeric(fields[9], 0)) ? 1 : 0
source_error_count = (count >= 10) ? numeric(fields[10], 0) : 0
source_last_error_at = (count >= 11) ? numeric(fields[11], 0) : 0
source_untracked_blocked = (count >= 12) ? numeric(fields[12], 0) : 0
source_initialized = (source_instance_id != "") ? 1 : 0
}
else if (count >= 4 && fields[1] == "source_client") {
key = decode_state_field(fields[2])
if (key != "") {
source_client_queries[key] = numeric(fields[3], 0)
source_client_blocked[key] = numeric(fields[4], 0)
}
}
else if (count >= 4 && fields[1] == "bucket") {
bucket = numeric(fields[2], 0)
if (bucket > 0) {
queries[bucket] = numeric(fields[3], 0)
blocked[bucket] = numeric(fields[4], 0)
}
}
else if (count >= 7 && fields[1] == "device") {
key = decode_state_field(fields[2])
mac = decode_state_field(fields[3])
ip = decode_state_field(fields[4])
hostname = decode_state_field(fields[5])
client_id = (count >= 8) ? decode_state_field(fields[8]) : ""
duid = (count >= 9) ? decode_state_field(fields[9]) : ""
key = register_device(key, mac, ip, hostname, client_id, duid)
if (key != "") {
legacy_device_queries[key] += numeric(fields[6], 0)
legacy_device_blocked[key] += numeric(fields[7], 0)
}
}
else if (count >= 5 && fields[1] == "device_bucket") {
key = decode_state_field(fields[2])
bucket = numeric(fields[3], 0)
if (key != "" && bucket > 0) {
key = register_device(key, "", "", "")
if (key != "") {
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] += numeric(fields[4], 0)
device_hour_blocked[composite] += numeric(fields[5], 0)
device_has_hourly[key] = 1
}
}
}
}
close(path)
return loaded_state
}
function journal_file_updated_at(path, line, fields, count, line_no, current_txn, current_updated, last_commit_line, latest_updated) {
if (path == "") {
return -1
}
line_no = 0
current_txn = ""
current_updated = 0
last_commit_line = 0
latest_updated = -1
while ((getline line < path) > 0) {
line_no++
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
current_updated = numeric(fields[4], 0)
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
last_commit_line = line_no
latest_updated = current_updated
current_txn = ""
current_updated = 0
}
}
close(path)
return (last_commit_line > 0) ? latest_updated : -1
}
function load_journal_file(path, min_updated_at, min_snapshot_seq, line, fields, count, line_no, current_txn, current_start, active_end, txn_updated, txn_started, txn_truncated, txn_session_started, txn_completed, txn_healthy, txn_error_count, txn_last_error, txn_generation_id, txn_snapshot_seq, txn_snapshot_reserved, key, mac, ip, hostname, client_id, duid, bucket, composite, committed_end) {
if (path == "") {
return 0
}
# First pass records only complete transaction ranges. A power loss can leave
# a partial append at the tail (or before a later retry); those records must
# never be replayed unless their matching commit marker is present.
line_no = 0
current_txn = ""
current_start = 0
while ((getline line < path) > 0) {
line_no++
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
current_start = line_no
}
else if (count >= 2 && fields[1] == "commit" && current_txn != "" && fields[2] == current_txn) {
committed_end[current_start] = line_no
current_txn = ""
current_start = 0
}
}
close(path)
line_no = 0
current_txn = ""
active_end = 0
while ((getline line < path) > 0) {
line_no++
count = split(line, fields, "\t")
if (count >= 11 && fields[1] == "begin") {
current_txn = fields[2]
active_end = committed_end[line_no] + 0
if (active_end <= 0) {
current_txn = ""
continue
}
txn_updated = numeric(fields[4], 0)
txn_started = numeric(fields[5], 0)
txn_truncated = numeric(fields[6], 0) ? 1 : 0
txn_session_started = numeric(fields[7], 0)
txn_completed = numeric(fields[8], 0)
txn_healthy = numeric(fields[9], 1) ? 1 : 0
txn_error_count = numeric(fields[10], 0)
txn_last_error = numeric(fields[11], 0)
txn_generation_id = (count >= 12) ? decode_state_field(fields[12]) : ""
txn_snapshot_seq = (count >= 13) ? numeric(fields[13], 0) : 0
txn_snapshot_reserved = (count >= 14) ? numeric(fields[14], 0) : 0
# Schema-v3 journal transactions carry generation-scoped snapshot_seq.
# Prefer it over wall-clock timestamps so a backward NTP correction does
# not make a newer committed transaction look older than the base state.
if (min_snapshot_seq >= 0 && txn_snapshot_seq > 0) {
if (txn_snapshot_seq <= min_snapshot_seq) {
current_txn = ""
active_end = 0
continue
}
}
else if (min_updated_at >= 0 && txn_updated <= min_updated_at) {
current_txn = ""
active_end = 0
continue
}
continue
}
if (current_txn == "" || line_no > active_end) {
continue
}
if (count >= 4 && fields[1] == "bucket") {
bucket = numeric(fields[2], 0)
if (bucket > 0) {
queries[bucket] = numeric(fields[3], 0)
blocked[bucket] = numeric(fields[4], 0)
}
continue
}
if (count >= 5 && fields[1] == "device") {
key = decode_state_field(fields[2])
mac = decode_state_field(fields[3])
ip = decode_state_field(fields[4])
hostname = decode_state_field(fields[5])
client_id = (count >= 6) ? decode_state_field(fields[6]) : ""
duid = (count >= 7) ? decode_state_field(fields[7]) : ""
register_device(key, mac, ip, hostname, client_id, duid)
continue
}
if (count >= 5 && fields[1] == "device_bucket") {
key = decode_state_field(fields[2])
bucket = numeric(fields[3], 0)
if (key != "" && bucket > 0) {
key = register_device(key, "", "", "")
if (key != "") {
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] = numeric(fields[4], 0)
device_hour_blocked[composite] = numeric(fields[5], 0)
device_has_hourly[key] = 1
}
}
continue
}
if (count >= 2 && fields[1] == "delete_device") {
key = decode_state_field(fields[2])
if (key != "") {
journal_replaying = 1
remove_device(key)
journal_replaying = 0
}
continue
}
if (count >= 2 && fields[1] == "commit" && fields[2] == current_txn && line_no == active_end) {
if (txn_started > 0) {
started_at = txn_started
}
if (txn_generation_id != "") {
generation_id = txn_generation_id
}
if (txn_snapshot_seq > snapshot_seq) {
snapshot_seq = txn_snapshot_seq
}
if (txn_snapshot_reserved > snapshot_seq_reserved_through) {
snapshot_seq_reserved_through = txn_snapshot_reserved
}
devices_truncated = txn_truncated
if (txn_session_started > 0) {
loaded_session_started_at = txn_session_started
}
if (txn_completed > last_journal_completed_bucket) {
last_journal_completed_bucket = txn_completed
}
persistent_updated_at = txn_updated
persistence_healthy = txn_healthy
persistent_error_count = txn_error_count
persistent_last_error_at = txn_last_error
updated_at = txn_updated
loaded_state = 1
state_schema_version = 6
current_txn = ""
active_end = 0
}
}
close(path)
journal_replaying = 0
return loaded_state
}
function migrate_legacy_device_totals(now, key, bucket, composite) {
if (state_schema_version >= 2) {
return
}
bucket = hour_start((updated_at > 0) ? updated_at : now)
for (key in device_seen) {
if (device_has_hourly[key]) {
continue
}
if ((legacy_device_queries[key] + 0) == 0 && (legacy_device_blocked[key] + 0) == 0) {
continue
}
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] += legacy_device_queries[key] + 0
device_hour_blocked[composite] += legacy_device_blocked[key] + 0
device_has_hourly[key] = 1
}
}
@@ -0,0 +1,545 @@
# SafeShield statistics: IPv4/IPv6 client identity resolution.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function normalize_client_id(value, mac, normalized, flat, mac_flat) {
normalized = tolower(value)
if (normalized == "" || normalized == "*") {
return ""
}
# A DHCP client-id of type 1 merely embeds the current Ethernet MAC and does
# not add any stability when Android/iOS private MAC rotation is in use.
flat = normalized
gsub(/[:.-]/, "", flat)
mac_flat = tolower(mac)
gsub(/:/, "", mac_flat)
if (mac_flat != "" && flat == ("01" mac_flat)) {
return ""
}
return normalized
}
function normalize_duid(value, normalized) {
normalized = tolower(value)
gsub(/[:.-]/, "", normalized)
if (normalized == "" || normalized !~ /^[0-9a-f]+$/) {
return ""
}
return normalized
}
function hostname_identity_key(value, normalized) {
normalized = tolower(value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", normalized)
return normalized
}
function hostname_is_mergeable(value, normalized) {
normalized = hostname_identity_key(value)
if (normalized == "" || normalized == "*" || normalized == "unknown") {
return 0
}
# Generic factory/default hostnames are hints at best and can easily belong
# to multiple devices on the same LAN. Never use them for automatic merges.
if (normalized == "iphone" || normalized == "ipad" || normalized == "android" || \
normalized == "galaxy" || normalized == "phone" || normalized == "tablet" || \
normalized == "laptop" || normalized == "desktop" || normalized == "pc" || \
normalized == "openwrt" || normalized == "router" || normalized == "localhost") {
return 0
}
return 1
}
function add_ipv6_neighbor(line, fields, count, i, ip, mac) {
count = split(line, fields, /[[:space:]]+/)
if (count < 5) {
return
}
ip = fields[1]
mac = ""
for (i = 2; i < count; i++) {
if (fields[i] == "lladdr") {
mac = normalize_mac(fields[i + 1])
break
}
}
if (ip == "" || mac == "") {
return
}
# Keep DHCP metadata authoritative when it already knows this exact address.
# The NDP table fills only the missing link-layer mapping.
if (!(ip in lease_mac)) {
lease_mac[ip] = mac
}
if (!(ip in lease_hostname)) {
lease_hostname[ip] = ""
}
}
function refresh_ipv6_neighbors( line) {
if (ipv6_neigh_file != "") {
while ((getline line < ipv6_neigh_file) > 0) {
add_ipv6_neighbor(line)
}
close(ipv6_neigh_file)
return
}
if (ipv6_neigh_command == "") {
return
}
# `ip -6 neigh show` resolves IPv6 privacy/temporary addresses back to a
# link-layer address without storing the queried IPv6 address as identity.
while ((ipv6_neigh_command | getline line) > 0) {
add_ipv6_neighbor(line)
}
close(ipv6_neigh_command)
}
function add_ipv6_identity(line, fields, count, ip, duid, hostname) {
count = split(line, fields, /[\t]+/)
if (count < 2) {
return
}
ip = fields[1]
duid = normalize_duid(fields[2])
hostname = (count >= 3 && fields[3] != "*") ? fields[3] : ""
if (ip == "" || duid == "") {
return
}
lease_duid[ip] = duid
if (!(ip in lease_hostname) || lease_hostname[ip] == "") {
lease_hostname[ip] = hostname
}
}
function refresh_ipv6_identities( line) {
if (ipv6_identity_file != "") {
while ((getline line < ipv6_identity_file) > 0) {
add_ipv6_identity(line)
}
close(ipv6_identity_file)
return
}
if (ipv6_identity_command == "") {
return
}
while ((ipv6_identity_command | getline line) > 0) {
add_ipv6_identity(line)
}
close(ipv6_identity_command)
}
function clear_identity_cache( ip) {
for (ip in identity_key_cache) {
delete identity_key_cache[ip]
delete identity_cache_expires[ip]
}
}
function cache_identity_key(ip, key, now) {
if (ip == "" || key == "") {
return key
}
identity_key_cache[ip] = key
identity_cache_expires[ip] = now + identity_cache_ttl
return key
}
function clear_lease_identity( ip, key) {
for (ip in lease_mac) {
delete lease_mac[ip]
}
for (ip in lease_hostname) {
delete lease_hostname[ip]
}
for (ip in lease_client_id) {
delete lease_client_id[ip]
}
for (ip in lease_duid) {
delete lease_duid[ip]
}
for (key in active_mac) {
delete active_mac[key]
delete active_mac_ip[key]
delete active_mac_hostname[key]
delete active_mac_client_id[key]
delete active_mac_duid[key]
}
for (key in active_hostname_count) {
delete active_hostname_count[key]
delete active_hostname_mac[key]
}
for (key in active_hostname_pair) {
delete active_hostname_pair[key]
}
}
function rebuild_active_identity_index( ip, mac, hostname, hostname_key, pair, client_id, duid) {
for (ip in lease_mac) {
mac = lease_mac[ip]
if (mac == "") {
continue
}
active_mac[mac] = 1
if (active_mac_ip[mac] == "" || ip !~ /:/) {
active_mac_ip[mac] = ip
}
hostname = lease_hostname[ip]
if (hostname != "" && hostname != "*") {
active_mac_hostname[mac] = hostname
hostname_key = hostname_identity_key(hostname)
pair = hostname_key SUBSEP mac
if (hostname_key != "" && !(pair in active_hostname_pair)) {
active_hostname_pair[pair] = 1
active_hostname_count[hostname_key]++
active_hostname_mac[hostname_key] = mac
}
}
client_id = lease_client_id[ip]
duid = lease_duid[ip]
if (client_id != "") {
active_mac_client_id[mac] = client_id
}
if (duid != "") {
active_mac_duid[mac] = duid
}
}
}
function move_device_hours(from_key, to_key, composite, parts, bucket, target, count, i, move_keys) {
if (from_key == to_key) {
return
}
# Do not mutate the associative array while iterating over it. Some awk
# implementations may skip entries when keys are added/deleted during the
# same for-in traversal, leaving orphan identity buckets behind.
count = 0
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
if (parts[1] == from_key) {
move_keys[++count] = composite
}
}
for (i = 1; i <= count; i++) {
composite = move_keys[i]
split(composite, parts, SUBSEP)
bucket = parts[2] + 0
target = device_bucket_key(to_key, bucket)
device_hour_queries[target] += device_hour_queries[composite] + 0
device_hour_blocked[target] += device_hour_blocked[composite] + 0
delete device_hour_queries[composite]
delete device_hour_blocked[composite]
delete move_keys[i]
}
delete device_has_hourly[from_key]
if (count > 0) {
device_has_hourly[to_key] = 1
}
}
function migrate_device(from_key, to_key, mac, ip, hostname, client_id, duid, old_client_id, old_duid, selected) {
if (to_key == "") {
return ""
}
if (from_key == "" || !(from_key in device_seen) || from_key == to_key) {
return register_device(to_key, mac, ip, hostname, client_id, duid)
}
old_client_id = device_client_id[from_key]
old_duid = device_duid[from_key]
if (client_id == "") {
client_id = old_client_id
}
if (duid == "") {
duid = old_duid
}
if (hostname == "") {
hostname = device_hostname[from_key]
}
if (ip == "") {
ip = device_ip[from_key]
}
# A migration replaces the old logical identity atomically in the retained
# statistics dataset. Journal the tombstone and a full replacement device so
# a reboot cannot resurrect the old MAC/IP identity.
if (!journal_replaying) {
journal_deleted_device[from_key] = 1
}
remove_device_metadata(from_key)
selected = register_device(to_key, mac, ip, hostname, client_id, duid)
if (selected == "") {
return ""
}
move_device_hours(from_key, selected)
if (!journal_replaying) {
journal_full_device[selected] = 1
}
return selected
}
function resolve_device_identity(ip, mac, hostname, client_id, duid, selected, owner, ip_key) {
if (mac == "") {
return register_device("ip:" ip, "", ip, hostname, client_id, duid)
}
selected = mac
client_id = normalize_client_id(client_id, mac)
duid = normalize_duid(duid)
# Strong identifiers survive private/randomized MAC changes. Move the full
# retained history to the currently observed MAC so existing Hub schema-v3
# consumers keep the id == mac contract while SafeShield emits one device.
if (duid != "") {
owner = duid_owner[duid]
if (owner != "" && owner != selected) {
selected = migrate_device(owner, selected, mac, ip, hostname, client_id, duid)
}
}
if (client_id != "") {
owner = client_id_owner[client_id]
if (owner != "" && owner != selected) {
selected = migrate_device(owner, selected, mac, ip, hostname, client_id, duid)
}
}
ip_key = "ip:" ip
if (ip_key in device_seen && ip_key != selected) {
selected = migrate_device(ip_key, selected, mac, ip, hostname, client_id, duid)
}
return register_device(selected, mac, ip, hostname, client_id, duid)
}
function devices_have_conflicting_strong_identity(left, right) {
if (device_client_id[left] != "" && device_client_id[right] != "" && \
device_client_id[left] != device_client_id[right]) {
return 1
}
if (device_duid[left] != "" && device_duid[right] != "" && device_duid[left] != device_duid[right]) {
return 1
}
return 0
}
function reconcile_hostname_devices( hostname_key, target_mac, target_key, target_ip, target_hostname, target_client_id, target_duid, key, count, i, candidates) {
for (hostname_key in active_hostname_count) {
if ((active_hostname_count[hostname_key] + 0) != 1 || !hostname_is_mergeable(hostname_key)) {
continue
}
target_mac = active_hostname_mac[hostname_key]
if (target_mac == "") {
continue
}
target_key = target_mac
target_ip = active_mac_ip[target_mac]
target_hostname = active_mac_hostname[target_mac]
target_client_id = active_mac_client_id[target_mac]
target_duid = active_mac_duid[target_mac]
target_key = register_device(target_key, target_mac, target_ip, target_hostname, target_client_id, target_duid)
if (target_key == "") {
continue
}
count = 0
for (key in device_seen) {
if (key == target_key || device_mac[key] == "" || device_mac[key] in active_mac) {
continue
}
if (hostname_identity_key(device_hostname[key]) != hostname_key) {
continue
}
if (devices_have_conflicting_strong_identity(key, target_key)) {
continue
}
candidates[++count] = key
}
for (i = 1; i <= count; i++) {
key = candidates[i]
target_key = migrate_device(key, target_key, target_mac, target_ip, target_hostname, target_client_id, target_duid)
delete candidates[i]
}
}
}
function reconcile_ip_devices( ip, ip_key, mac, hostname, client_id, duid, owner) {
for (ip in lease_mac) {
mac = lease_mac[ip]
if (mac == "") {
continue
}
hostname = lease_hostname[ip]
client_id = lease_client_id[ip]
duid = lease_duid[ip]
ip_key = "ip:" ip
# Avoid materializing every DHCP/NDP lease as a zero-counter device.
# Reconcile eagerly only when retained history or a strong identity owner
# already exists; normal traffic registers a new active device on demand.
owner = ""
if (duid != "") {
owner = duid_owner[duid]
}
if (owner == "" && client_id != "") {
owner = client_id_owner[client_id]
}
if (!(ip_key in device_seen) && !(mac in device_seen) && owner == "") {
continue
}
resolve_device_identity(ip, mac, hostname, client_id, duid)
}
}
function refresh_leases(now, force, line, fields, count, mac, ip, hostname, client_id) {
if (!force && last_lease_refresh > 0 && (now - last_lease_refresh) < lease_refresh_interval) {
return
}
clear_identity_cache()
clear_lease_identity()
while ((getline line < lease_file) > 0) {
count = split(line, fields, /[[:space:]]+/)
if (count < 4) {
continue
}
mac = normalize_mac(fields[2])
ip = fields[3]
hostname = fields[4]
client_id = (count >= 5) ? normalize_client_id(fields[5], mac) : ""
if (mac == "" || ip == "") {
continue
}
lease_mac[ip] = mac
lease_hostname[ip] = (hostname == "*") ? "" : hostname
lease_client_id[ip] = client_id
}
close(lease_file)
# DHCP leases are not guaranteed to contain every active client. Static-IP
# clients and lease-file update windows can otherwise recreate an ip:*
# identity after the device has already been identified by MAC. Use the
# kernel ARP table as the current IPv4 identity fallback.
while ((getline line < arp_file) > 0) {
count = split(line, fields, /[[:space:]]+/)
if (count < 4) {
continue
}
ip = fields[1]
mac = normalize_mac(fields[4])
if (ip == "" || mac == "") {
continue
}
if (!(ip in lease_mac)) {
lease_mac[ip] = mac
lease_hostname[ip] = ""
lease_client_id[ip] = ""
}
}
close(arp_file)
# odhcpd exposes the DHCPv6 DUID while the kernel NDP cache exposes the MAC.
# Combining the two lets privacy IPv6 addresses and private MAC rotations
# converge without treating each temporary IPv6 address as a new device.
refresh_ipv6_identities()
refresh_ipv6_neighbors()
rebuild_active_identity_index()
reconcile_ip_devices()
reconcile_hostname_devices()
last_lease_refresh = now
}
function compact_stale_provisional_devices(now, cutoff, key, last_bucket, count, i, stale) {
cutoff = hour_start(now) - (provisional_retention_hours * 3600)
count = 0
for (key in device_seen) {
if (key !~ /^ip:/ || device_client_id[key] != "" || device_duid[key] != "") {
continue
}
last_bucket = device_last_bucket[key] + 0
if (last_bucket > 0 && last_bucket <= cutoff) {
stale[++count] = key
}
}
for (i = 1; i <= count; i++) {
key = stale[i]
migrate_device(key, "unknown", "", "", "Unknown devices", "", "")
delete stale[i]
}
return count
}
function is_internal_statistics_client(ip) {
return (ip == "::1" || ip ~ /^127\./)
}
function device_key_for_ip(ip, now, cached_key, mac, hostname, client_id, duid, selected) {
if (ip == "" || is_internal_statistics_client(ip)) {
return ""
}
if ((ip in identity_key_cache) && (identity_cache_expires[ip] + 0) > now) {
cached_key = identity_key_cache[ip]
if (cached_key != "") {
return cached_key
}
}
delete identity_key_cache[ip]
delete identity_cache_expires[ip]
refresh_leases(now, 0)
mac = lease_mac[ip]
hostname = lease_hostname[ip]
client_id = lease_client_id[ip]
duid = lease_duid[ip]
if (mac != "") {
selected = resolve_device_identity(ip, mac, hostname, client_id, duid)
}
else {
selected = register_device("ip:" ip, "", ip, hostname, client_id, duid)
}
return cache_identity_key(ip, selected, now)
}
function record_device(ip, query_delta, blocked_delta, now, key, bucket, composite) {
key = device_key_for_ip(ip, now)
if (key == "") {
return
}
bucket = hour_start(now)
composite = device_bucket_key(key, bucket)
device_hour_queries[composite] += 0
device_hour_blocked[composite] += 0
if (query_delta > 0) {
device_hour_queries[composite] += query_delta
}
if (blocked_delta > 0) {
device_hour_blocked[composite] += blocked_delta
}
device_has_hourly[key] = 1
}
@@ -0,0 +1,86 @@
# SafeShield statistics: aggregation, log parsing, and serialization helpers.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function recompute_totals( bucket, composite, parts, key, stale_count, i) {
total_queries = 0
total_blocked = 0
for (bucket in queries) {
total_queries += queries[bucket] + 0
total_blocked += blocked[bucket] + 0
}
for (key in device_seen) {
device_queries[key] = 0
device_blocked[key] = 0
delete device_first_bucket[key]
delete device_last_bucket[key]
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
key = parts[1]
bucket = parts[2] + 0
device_queries[key] += device_hour_queries[composite] + 0
device_blocked[key] += device_hour_blocked[composite] + 0
if (!(key in device_first_bucket) || bucket < (device_first_bucket[key] + 0)) {
device_first_bucket[key] = bucket
}
if (!(key in device_last_bucket) || bucket > (device_last_bucket[key] + 0)) {
device_last_bucket[key] = bucket
}
}
stale_count = 0
for (key in device_seen) {
if ((device_queries[key] + 0) == 0 && (device_blocked[key] + 0) == 0) {
stale_devices[++stale_count] = key
}
}
for (i = 1; i <= stale_count; i++) {
remove_device(stale_devices[i])
delete stale_devices[i]
}
}
function extract_query_client(line, client) {
client = line
if (client !~ / from /) {
return ""
}
sub(/^.* from /, "", client)
sub(/[[:space:]].*$/, "", client)
return client
}
function extract_extra_client(line, payload, fields, count, client) {
payload = line
sub(/^.*dnsmasq\[[0-9]+\]:[[:space:]]*/, "", payload)
count = split(payload, fields, /[[:space:]]+/)
if (count < 3 || fields[1] !~ /^[0-9]+$/) {
return ""
}
client = fields[2]
sub(/\/[0-9]+$/, "", client)
return client
}
function shell_quote(value, quoted) {
quoted = value
gsub(/'/, "'\\''", quoted)
return "'" quoted "'"
}
function json_escape(value, escaped) {
escaped = value
gsub(/\\/, "\\\\", escaped)
gsub(/"/, "\\\"", escaped)
gsub(/\r/, "\\r", escaped)
gsub(/\n/, "\\n", escaped)
gsub(/\t/, "\\t", escaped)
return escaped
}
function replace_file(tmp, final, rc) {
rc = system("mv -f " shell_quote(tmp) " " shell_quote(final))
return rc == 0
}
@@ -0,0 +1,349 @@
# SafeShield statistics: tmpfs state and persistent journal writes.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function save_state_file(path, now, tmp, bucket, cutoff, key, composite, parts) {
if (path == "") {
return 0
}
tmp = path ".tmp"
printf "meta\t%d\t%d\t%d\t%d\t%d\t%d\t6\t%d\t%d\t%d\t%d\t%d\t%d\t%s\t%d\t%d\n", \
started_at, updated_at, total_queries, total_blocked, devices_truncated, \
persistent_updated_at, session_started_at, persistence_healthy, \
persistent_error_count, persistent_last_error_at, persistent_compacted_at, \
last_journal_completed_bucket, normalize_state_field(generation_id), \
snapshot_seq, snapshot_seq_reserved_through > tmp
if (source_initialized || source_error_count > 0) {
printf "source\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", \
normalize_state_field(source_instance_id), \
normalize_state_field(source_transport_scope), \
source_client_capacity, source_tracked_clients, source_untracked_queries, \
source_total_queries, source_total_blocked, source_available, \
source_error_count, source_last_error_at, source_untracked_blocked >> tmp
}
if (source_initialized) {
for (key in source_client_queries) {
printf "source_client\t%s\t%d\t%d\n", \
normalize_state_field(key), \
source_client_queries[key] + 0, \
source_client_blocked[key] + 0 >> tmp
}
}
cutoff = hour_start(now) - ((retention_hours - 1) * 3600)
for (bucket = cutoff; bucket <= hour_start(now); bucket += 3600) {
if ((bucket in queries) || (bucket in blocked)) {
printf "bucket\t%d\t%d\t%d\n", bucket, queries[bucket] + 0, blocked[bucket] + 0 >> tmp
}
}
for (key in device_seen) {
printf "device\t%s\t%s\t%s\t%s\t%d\t%d\t%s\t%s\n", \
normalize_state_field(key), \
normalize_state_field(device_mac[key]), \
normalize_state_field(device_ip[key]), \
normalize_state_field(device_hostname[key]), \
device_queries[key] + 0, \
device_blocked[key] + 0, \
normalize_state_field(device_client_id[key]), \
normalize_state_field(device_duid[key]) >> tmp
}
for (composite in device_hour_queries) {
split(composite, parts, SUBSEP)
if ((parts[2] + 0) < cutoff) {
continue
}
printf "device_bucket\t%s\t%d\t%d\t%d\n", \
normalize_state_field(parts[1]), \
parts[2] + 0, \
device_hour_queries[composite] + 0, \
device_hour_blocked[composite] + 0 >> tmp
}
close(tmp)
if (!replace_file(tmp, path)) {
system("rm -f " shell_quote(tmp))
return 0
}
return 1
}
function save_sequence_reservation(path, generation, reserved_through, tmp) {
if (path == "" || generation == "" || reserved_through <= 0) {
return 0
}
tmp = path ".tmp"
printf "reserve\t%s\t%d\n", normalize_state_field(generation), reserved_through > tmp
close(tmp)
if (!replace_file(tmp, path)) {
system("rm -f " shell_quote(tmp))
return 0
}
return 1
}
function ensure_snapshot_sequence_reservation(now, next_seq, reserved_through) {
next_seq = snapshot_seq + 1
if (sequence_file == "") {
snapshot_seq = next_seq
if (snapshot_seq > snapshot_seq_reserved_through) {
snapshot_seq_reserved_through = snapshot_seq
}
return 1
}
if (next_seq > snapshot_seq_reserved_through) {
reserved_through = next_seq + snapshot_seq_reservation_size - 1
if (!save_sequence_reservation(sequence_file, generation_id, reserved_through)) {
persistence_healthy = 0
persistent_error_count++
persistent_last_error_at = now
return 0
}
snapshot_seq_reserved_through = reserved_through
}
snapshot_seq = next_seq
return 1
}
function append_file(source, target, rc) {
rc = system("cat " shell_quote(source) " >> " shell_quote(target))
return rc == 0
}
function clear_journal_file(path, rc) {
if (path == "") {
return 1
}
rc = system(": > " shell_quote(path))
return rc == 0
}
function schedule_next_persistent(now) {
next_persistent_at = hour_start(now) + persistent_interval
while (next_persistent_at <= now) {
next_persistent_at += persistent_interval
}
}
function schedule_next_compaction(now) {
if (persistent_compact_interval <= 0) {
next_compact_at = 0
return
}
if (persistent_compacted_at <= 0) {
persistent_compacted_at = now
}
next_compact_at = persistent_compacted_at + persistent_compact_interval
while (next_compact_at <= now) {
next_compact_at += persistent_compact_interval
}
}
function journal_has_pending_deletes( key) {
for (key in journal_deleted_device) {
return 1
}
return 0
}
function journal_has_pending_full_devices( key) {
for (key in journal_full_device) {
return 1
}
return 0
}
function clear_journal_pending( key) {
for (key in journal_deleted_device) {
delete journal_deleted_device[key]
}
for (key in journal_full_device) {
delete journal_full_device[key]
}
}
function write_journal_transaction(now, first_bucket, last_bucket, completed_through, tmp, txn_id, bucket, key, composite, wrote_device, device_first, device_last, cutoff) {
if (persistent_journal_file == "") {
return 0
}
if (first_bucket > last_bucket && !journal_has_pending_deletes() && !journal_has_pending_full_devices()) {
return 1
}
tmp = state_file ".journal.tmp"
txn_id = sprintf("%d-%d-%d", now, event_index, ++journal_txn_seq)
printf "begin\t%s\t4\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%s\t%d\t%d\n", \
txn_id, now, started_at, devices_truncated, session_started_at, \
completed_through, persistence_healthy, persistent_error_count, \
persistent_last_error_at, normalize_state_field(generation_id), \
snapshot_seq, snapshot_seq_reserved_through > tmp
for (key in journal_deleted_device) {
printf "delete_device\t%s\n", normalize_state_field(key) >> tmp
}
for (bucket = first_bucket; bucket <= last_bucket; bucket += 3600) {
if ((bucket in queries) || (bucket in blocked)) {
printf "bucket\t%d\t%d\t%d\n", bucket, queries[bucket] + 0, blocked[bucket] + 0 >> tmp
}
}
# Journal writes normally cover one completed hour. Iterate at most
# max_devices for that hour instead of scanning every retained device-hour
# entry (up to max_devices * retention_hours) on low-end routers. Identity
# migration is the exception: its target MAC receives the full retained
# history in the same transaction as the old ip:* tombstone.
cutoff = hour_start(now) - ((retention_hours - 1) * 3600)
for (key in device_seen) {
wrote_device = 0
device_first = first_bucket
device_last = last_bucket
if (key in journal_full_device) {
device_first = cutoff
device_last = hour_start(now)
}
for (bucket = device_first; bucket <= device_last; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
if (!wrote_device) {
printf "device\t%s\t%s\t%s\t%s\t%s\t%s\n", \
normalize_state_field(key), \
normalize_state_field(device_mac[key]), \
normalize_state_field(device_ip[key]), \
normalize_state_field(device_hostname[key]), \
normalize_state_field(device_client_id[key]), \
normalize_state_field(device_duid[key]) >> tmp
wrote_device = 1
}
printf "device_bucket\t%s\t%d\t%d\t%d\n", \
normalize_state_field(key), bucket, \
device_hour_queries[composite] + 0, \
device_hour_blocked[composite] + 0 >> tmp
}
}
printf "commit\t%s\n", txn_id >> tmp
close(tmp)
if (!append_file(tmp, persistent_journal_file)) {
system("rm -f " shell_quote(tmp))
return 0
}
system("rm -f " shell_quote(tmp))
clear_journal_pending()
return 1
}
function flush_completed_journal(now, current_bucket, target_bucket, cutoff, first_bucket) {
current_bucket = hour_start(now)
target_bucket = current_bucket - 3600
cutoff = current_bucket - ((retention_hours - 1) * 3600)
first_bucket = last_journal_completed_bucket + 3600
if (last_journal_completed_bucket <= 0 || first_bucket < cutoff) {
first_bucket = cutoff
}
if (target_bucket < first_bucket && !journal_has_pending_deletes() && !journal_has_pending_full_devices()) {
return 1
}
if (!write_journal_transaction(now, first_bucket, target_bucket, target_bucket)) {
return 0
}
if (target_bucket >= first_bucket) {
last_journal_completed_bucket = target_bucket
}
return 1
}
function flush_current_journal(now, current_bucket) {
current_bucket = hour_start(now)
return write_journal_transaction(now, current_bucket, current_bucket, last_journal_completed_bucket)
}
function maybe_compact_persistent(now, previous_compacted) {
if (persistent_state_file == "" || persistent_journal_file == "" || persistent_compact_interval <= 0) {
return 1
}
if (next_compact_at <= 0 || now < next_compact_at) {
return 1
}
previous_compacted = persistent_compacted_at
persistent_compacted_at = now
if (!save_state_file(persistent_state_file, now)) {
persistent_compacted_at = previous_compacted
persistent_error_count++
persistent_last_error_at = now
next_compact_at = now + ((persistent_retry_interval > 3600) ? persistent_retry_interval : 3600)
return 0
}
# The base snapshot is already complete. If journal truncation is interrupted,
# startup ignores committed transactions whose updated_at is not newer than
# the base snapshot, preventing retained absolute upserts from rolling it back.
if (!clear_journal_file(persistent_journal_file)) {
persistent_error_count++
persistent_last_error_at = now
}
schedule_next_compaction(now)
return 1
}
function save_persistent_snapshot(now, previous_updated) {
previous_updated = persistent_updated_at
persistent_updated_at = now
persistence_healthy = 1
if (!save_state_file(persistent_state_file, now)) {
persistent_updated_at = previous_updated
persistence_healthy = 0
persistent_error_count++
persistent_last_error_at = now
next_persistent_at = now + persistent_retry_interval
return 0
}
persistence_healthy = 1
return 1
}
function maybe_save_persistent(now, force, previous_updated, ok) {
if (persistent_state_file == "" && persistent_journal_file == "") {
return 1
}
if (!force && now < next_persistent_at) {
return 1
}
# Compatibility fallback for callers that do not configure a journal.
if (persistent_journal_file == "") {
if (!save_persistent_snapshot(now)) {
return 0
}
schedule_next_persistent(now)
return 1
}
previous_updated = persistent_updated_at
persistence_healthy = 1
ok = flush_completed_journal(now)
if (ok && force) {
ok = flush_current_journal(now)
}
if (!ok) {
persistent_updated_at = previous_updated
persistence_healthy = 0
persistent_error_count++
persistent_last_error_at = now
next_persistent_at = now + persistent_retry_interval
return 0
}
persistent_updated_at = now
persistence_healthy = 1
if (!force) {
maybe_compact_persistent(now)
}
schedule_next_persistent(now)
return 1
}
@@ -0,0 +1,266 @@
# SafeShield statistics: JSON snapshots and persistence scheduling.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function statistics_first_hour(now, current_hour, cutoff, first_hour) {
current_hour = hour_start(now)
cutoff = current_hour - ((retention_hours - 1) * 3600)
first_hour = hour_start(started_at)
if (first_hour < cutoff) {
first_hour = cutoff
}
return first_hour
}
function is_unknown_output_device(key) {
return (key == "unknown" || (device_mac[key] == "" && key ~ /^ip:/))
}
function save_json_range(path, now, first_hour, last_hour, include_empty_devices, tmp, bucket, comma, key, identified, device_comma, identity_comma, composite, device_first_hour, device_last_hour, persistence_enabled, persistent, healthy, volatile_state, storage, persistence_mode, truncated, range_queries, range_blocked, device_range_queries, device_range_blocked, device_has_rows, unknown_has_rows, unknown_queries, unknown_blocked) {
if (path == "") {
return 1
}
tmp = path ".tmp"
if (first_hour > last_hour) {
first_hour = last_hour
}
persistence_enabled = (persistent_state_file != "" || persistent_journal_file != "") ? "true" : "false"
persistent = (persistence_enabled == "true" && persistence_healthy && persistent_updated_at > 0) ? "true" : "false"
healthy = (persistence_enabled == "true") ? (persistence_healthy ? "true" : "false") : "true"
volatile_state = (persistence_enabled == "true") ? "false" : "true"
storage = (persistence_enabled == "true") ? "tmpfs+flash" : "tmpfs"
persistence_mode = (persistent_journal_file != "") ? "journal" : ((persistent_state_file != "") ? "snapshot" : "none")
truncated = devices_truncated ? "true" : "false"
range_queries = 0
range_blocked = 0
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
range_queries += queries[bucket] + 0
range_blocked += blocked[bucket] + 0
delete unknown_hour_queries[bucket]
delete unknown_hour_blocked[bucket]
}
# IP-only identities are intentionally retained internally for a short period
# so a later DHCP/ARP/NDP observation can migrate their history to a real
# device. They are serialized as one logical "unknown" client so temporary
# IPv6 privacy addresses cannot create unbounded client records in the Hub/UI.
unknown_has_rows = 0
unknown_queries = 0
unknown_blocked = 0
for (key in device_seen) {
if (!is_unknown_output_device(key)) {
continue
}
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
unknown_has_rows = 1
unknown_hour_queries[bucket] += device_hour_queries[composite] + 0
unknown_hour_blocked[bucket] += device_hour_blocked[composite] + 0
unknown_queries += device_hour_queries[composite] + 0
unknown_blocked += device_hour_blocked[composite] + 0
}
}
printf "{\"schema\":{\"name\":\"safeshield.statistics\",\"version\":3}," > tmp
printf "\"volatile\":%s,\"storage\":\"%s\",\"persistent\":%s,", volatile_state, storage, persistent >> tmp
printf "\"persistence_enabled\":%s,\"persistence_healthy\":%s,", persistence_enabled, healthy >> tmp
printf "\"persistence_mode\":\"%s\",", persistence_mode >> tmp
printf "\"persistent_error_count\":%d,\"persistent_last_error_at\":%d,", persistent_error_count, persistent_last_error_at >> tmp
printf "\"persistent_updated_at\":%d,\"persistent_checkpoint_interval_s\":%d,", persistent_updated_at, persistent_interval >> tmp
printf "\"persistent_compacted_at\":%d,\"persistent_compact_interval_s\":%d,", persistent_compacted_at, persistent_compact_interval >> tmp
printf "\"snapshot_interval_s\":%d,", snapshot_interval >> tmp
printf "\"generation_id\":\"%s\",", json_escape(generation_id) >> tmp
printf "\"snapshot_seq\":%d,", snapshot_seq >> tmp
printf "\"source\":{\"backend\":\"dnsmasq_ubus\",\"available\":%s,", source_available ? "true" : "false" >> tmp
printf "\"instance_id\":\"%s\",\"transport_scope\":\"%s\",", \
json_escape(source_instance_id), json_escape(source_transport_scope) >> tmp
printf "\"client_capacity\":%d,\"tracked_clients\":%d,\"untracked_queries\":%d,", \
source_client_capacity, source_tracked_clients, source_untracked_queries >> tmp
printf "\"untracked_blocked\":%d,", source_untracked_blocked >> tmp
printf "\"poll_error_count\":%d,\"last_error_at\":%d},", \
source_error_count, source_last_error_at >> tmp
printf "\"started_at\":%d,\"session_started_at\":%d,\"updated_at\":%d,", started_at, session_started_at, now >> tmp
printf "\"retention_hours\":%d,", retention_hours >> tmp
printf "\"device_limit\":%d,\"devices_truncated\":%s,", max_devices, truncated >> tmp
printf "\"totals\":{\"queries\":%d,\"blocked\":%d},", range_queries, range_blocked >> tmp
printf "\"hourly\":[" >> tmp
comma = ""
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
printf "%s{\"bucket_start\":%d,\"queries\":%d,\"blocked\":%d}", comma, bucket, queries[bucket] + 0, blocked[bucket] + 0 >> tmp
comma = ","
}
printf "],\"devices\":[" >> tmp
comma = ""
for (key in device_seen) {
if (is_unknown_output_device(key)) {
continue
}
device_first_hour = device_first_bucket[key] + 0
device_last_hour = device_last_bucket[key] + 0
if (device_first_hour <= 0 || device_first_hour < first_hour) {
device_first_hour = first_hour
}
if (device_last_hour <= 0 || device_last_hour > last_hour) {
device_last_hour = last_hour
}
device_range_queries = 0
device_range_blocked = 0
device_has_rows = 0
if (device_first_hour <= device_last_hour) {
for (bucket = device_first_hour; bucket <= device_last_hour; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
device_has_rows = 1
device_range_queries += device_hour_queries[composite] + 0
device_range_blocked += device_hour_blocked[composite] + 0
}
}
if (!include_empty_devices && !device_has_rows) {
continue
}
identified = (device_mac[key] != "") ? "true" : "false"
printf "%s{\"id\":\"%s\",\"mac\":\"%s\",\"ip\":\"%s\",\"hostname\":\"%s\",\"identified\":%s,\"queries\":%d,\"blocked\":%d,\"identities\":[", \
comma, \
json_escape(key), \
json_escape(device_mac[key]), \
json_escape(device_ip[key]), \
json_escape(device_hostname[key]), \
identified, \
device_range_queries, \
device_range_blocked >> tmp
identity_comma = ""
if (device_duid[key] != "") {
printf "%s{\"type\":\"dhcpv6_duid\",\"value\":\"%s\"}", \
identity_comma, json_escape(device_duid[key]) >> tmp
identity_comma = ","
}
if (device_client_id[key] != "") {
printf "%s{\"type\":\"dhcp_client_id\",\"value\":\"%s\"}", \
identity_comma, json_escape(device_client_id[key]) >> tmp
identity_comma = ","
}
if (device_mac[key] != "") {
printf "%s{\"type\":\"mac\",\"value\":\"%s\"}", \
identity_comma, json_escape(device_mac[key]) >> tmp
}
printf "],\"hourly\":[" >> tmp
device_comma = ""
if (device_first_hour <= device_last_hour) {
for (bucket = device_first_hour; bucket <= device_last_hour; bucket += 3600) {
composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
continue
}
printf "%s{\"bucket_start\":%d,\"queries\":%d,\"blocked\":%d}", \
device_comma, bucket, device_hour_queries[composite] + 0, device_hour_blocked[composite] + 0 >> tmp
device_comma = ","
}
}
printf "]}" >> tmp
comma = ","
}
if (unknown_has_rows) {
printf "%s{\"id\":\"unknown\",\"mac\":\"\",\"ip\":\"\",\"hostname\":\"Unknown devices\",\"identified\":false,\"queries\":%d,\"blocked\":%d,\"hourly\":[", \
comma, unknown_queries, unknown_blocked >> tmp
device_comma = ""
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
if (!((bucket in unknown_hour_queries) || (bucket in unknown_hour_blocked))) {
continue
}
printf "%s{\"bucket_start\":%d,\"queries\":%d,\"blocked\":%d}", \
device_comma, bucket, unknown_hour_queries[bucket] + 0, unknown_hour_blocked[bucket] + 0 >> tmp
device_comma = ","
}
printf "]}" >> tmp
comma = ","
}
printf "]}\n" >> tmp
close(tmp)
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
delete unknown_hour_queries[bucket]
delete unknown_hour_blocked[bucket]
}
return replace_file(tmp, path)
}
function save_json(now, current_hour, first_hour) {
current_hour = hour_start(now)
first_hour = statistics_first_hour(now)
return save_json_range(json_file, now, first_hour, current_hour, 1)
}
function save_upload_json(now, current_hour, first_hour, retained_first_hour) {
if (upload_json_file == "") {
return 1
}
current_hour = hour_start(now)
retained_first_hour = statistics_first_hour(now)
first_hour = current_hour - ((upload_window_hours - 1) * 3600)
if (first_hour < retained_first_hour) {
first_hour = retained_first_hour
}
return save_json_range(upload_json_file, now, first_hour, current_hour, 0)
}
function persistence_metadata_signature() {
return persistent_updated_at SUBSEP persistence_healthy SUBSEP persistent_error_count SUBSEP \
persistent_last_error_at SUBSEP persistent_compacted_at SUBSEP last_journal_completed_bucket
}
function save_snapshot(now, force_persistent, force_snapshot, serialize_snapshot, previous_persistence_metadata, persistence_configured) {
persistence_configured = (persistent_state_file != "" || persistent_journal_file != "")
serialize_snapshot = snapshot_dirty || force_snapshot || (force_persistent && persistence_configured)
if (serialize_snapshot) {
prune_buckets(now)
recompute_totals()
if (compact_stale_provisional_devices(now) > 0) {
recompute_totals()
}
updated_at = now
if (!ensure_snapshot_sequence_reservation(now)) {
return 0
}
}
previous_persistence_metadata = persistence_metadata_signature()
maybe_save_persistent(now, force_persistent)
if (persistence_metadata_signature() != previous_persistence_metadata) {
serialize_snapshot = 1
}
if (!serialize_snapshot) {
last_snapshot = now
return 1
}
if (!save_state_file(state_file, now)) {
return 0
}
if (!save_json(now)) {
return 0
}
# The cloud-upload projection is auxiliary. A transient tmpfs write failure
# must not stop local statistics collection; the uploader will retry once a
# later snapshot is available.
if (upload_json_file != "") {
save_upload_json(now)
}
snapshot_dirty = 0
last_snapshot = now
return 1
}
@@ -0,0 +1,422 @@
# SafeShield statistics: collector lifecycle and dnsmasq event dispatch.
# Loaded together with the other statistics/*.awk modules by safeshield-statsd.
function record_event(query_delta, blocked_delta, client_ip, now, bucket) {
if (is_internal_statistics_client(client_ip)) {
return
}
now = current_time()
bucket = hour_start(now)
snapshot_dirty = 1
queries[bucket] += 0
blocked[bucket] += 0
if (query_delta > 0) {
queries[bucket] += query_delta
}
if (blocked_delta > 0) {
blocked[bucket] += blocked_delta
}
record_device(client_ip, query_delta, blocked_delta, now)
event_index++
if ((now - last_snapshot) >= snapshot_interval) {
save_snapshot(now, 0, 0)
}
}
function source_counter_delta(current, previous, same_epoch) {
current = numeric(current, 0)
previous = numeric(previous, 0)
if (!same_epoch || current < previous) {
return current
}
return current - previous
}
function clear_poll_clients( address) {
for (address in poll_client_seen) {
delete poll_client_seen[address]
delete poll_client_queries[address]
delete poll_client_blocked[address]
}
}
function replace_source_clients( address) {
for (address in source_client_queries) {
delete source_client_queries[address]
delete source_client_blocked[address]
}
for (address in poll_client_seen) {
source_client_queries[address] = poll_client_queries[address] + 0
source_client_blocked[address] = poll_client_blocked[address] + 0
}
}
function apply_source_snapshot( now, bucket, had_source, same_epoch, query_delta, blocked_delta, internal_queries, internal_blocked, address, client_queries_delta, client_blocked_delta) {
if (!poll_active || poll_instance_id == "") {
return
}
now = current_time()
bucket = hour_start(now)
had_source = source_initialized && !force_rebaseline
same_epoch = had_source && poll_instance_id == source_instance_id
if (!had_source) {
query_delta = 0
blocked_delta = 0
}
else {
query_delta = source_counter_delta(poll_total_queries, source_total_queries, same_epoch)
blocked_delta = source_counter_delta(poll_total_blocked, source_total_blocked, same_epoch)
}
internal_queries = 0
internal_blocked = 0
for (address in poll_client_seen) {
if (!had_source) {
client_queries_delta = 0
client_blocked_delta = 0
}
else {
client_queries_delta = source_counter_delta(poll_client_queries[address], \
source_client_queries[address], same_epoch)
client_blocked_delta = source_counter_delta(poll_client_blocked[address], \
source_client_blocked[address], same_epoch)
}
if (is_internal_statistics_client(address)) {
internal_queries += client_queries_delta
internal_blocked += client_blocked_delta
continue
}
if (client_queries_delta > 0 || client_blocked_delta > 0) {
record_device(address, client_queries_delta, client_blocked_delta, now)
}
}
query_delta -= internal_queries
blocked_delta -= internal_blocked
if (query_delta < 0) {
query_delta = 0
}
if (blocked_delta < 0) {
blocked_delta = 0
}
if (query_delta > 0 || blocked_delta > 0) {
queries[bucket] += 0
blocked[bucket] += 0
queries[bucket] += query_delta
blocked[bucket] += blocked_delta
}
source_initialized = 1
source_available = 1
source_instance_id = poll_instance_id
source_transport_scope = poll_transport_scope
source_client_capacity = poll_client_capacity
source_tracked_clients = poll_tracked_clients
source_untracked_queries = poll_untracked_queries
source_untracked_blocked = poll_untracked_blocked
source_total_queries = poll_total_queries
source_total_blocked = poll_total_blocked
replace_source_clients()
poll_active = 0
clear_poll_clients()
snapshot_dirty = 1
event_index++
if (save_snapshot(now, 0, 0) && force_rebaseline) {
if (rebaseline_file != "") {
system("rm -f " shell_quote(rebaseline_file))
}
force_rebaseline = 0
}
}
function record_source_error( now) {
now = current_time()
source_available = 0
source_error_count++
source_last_error_at = now
snapshot_dirty = 1
event_index++
save_snapshot(now, 0, 0)
}
BEGIN {
state_file = (state_file != "") ? state_file : "/tmp/safeshield/statistics/state.tsv"
json_file = (json_file != "") ? json_file : "/tmp/safeshield/statistics/statistics.json"
upload_json_file = (upload_json_file != "") ? upload_json_file : ""
persistent_state_file = (persistent_state_file != "") ? persistent_state_file : ""
persistent_journal_file = (persistent_journal_file != "") ? persistent_journal_file : ""
sequence_file = (sequence_file != "") ? sequence_file : ""
lease_file = (lease_file != "") ? lease_file : "/tmp/dhcp.leases"
arp_file = (arp_file != "") ? arp_file : "/proc/net/arp"
ipv6_neigh_file = (ipv6_neigh_file != "") ? ipv6_neigh_file : ""
ipv6_neigh_command = (ipv6_neigh_command != "") ? ipv6_neigh_command : ""
ipv6_identity_file = (ipv6_identity_file != "") ? ipv6_identity_file : ""
ipv6_identity_command = (ipv6_identity_command != "") ? ipv6_identity_command : ""
snapshot_interval = numeric(snapshot_interval, 60)
retention_hours = numeric(retention_hours, 168)
upload_window_hours = numeric(upload_window_hours, 2)
lease_refresh_interval = numeric(lease_refresh_interval, 60)
identity_cache_ttl = numeric(identity_cache_ttl, 60)
provisional_retention_hours = numeric(provisional_retention_hours, 6)
persistent_interval = numeric(persistent_interval, 3600)
persistent_retry_interval = numeric(persistent_retry_interval, 300)
persistent_compact_interval = numeric(persistent_compact_interval, 604800)
snapshot_seq_reservation_size = numeric(snapshot_seq_reservation_size, 4096)
max_devices = numeric(max_devices, 128)
fixed_now = numeric(fixed_now, 0)
fixed_step = numeric(fixed_step, 0)
generation_seed = normalize_state_field(generation_seed)
force_rebaseline = numeric(force_rebaseline, 0) ? 1 : 0
rebaseline_file = (rebaseline_file != "") ? rebaseline_file : ""
if (generation_seed == "*") {
generation_seed = ""
}
event_index = 0
snapshot_dirty = 0
device_count = 0
devices_truncated = 0
last_lease_refresh = 0
loaded_state = 0
loaded_from_persistent = 0
state_schema_version = 1
snapshot_seq = 0
snapshot_seq_reserved_through = 0
persistent_updated_at = 0
persistent_compacted_at = 0
last_journal_completed_bucket = 0
journal_txn_seq = 0
journal_replaying = 0
persistence_healthy = (persistent_state_file != "" || persistent_journal_file != "") ? 1 : 0
persistent_error_count = 0
persistent_last_error_at = 0
source_initialized = 0
source_available = 0
source_instance_id = ""
source_transport_scope = ""
source_client_capacity = 0
source_tracked_clients = 0
source_untracked_queries = 0
source_untracked_blocked = 0
source_total_queries = 0
source_total_blocked = 0
source_error_count = 0
source_last_error_at = 0
poll_active = 0
if (snapshot_interval < 1) {
snapshot_interval = 60
}
if (retention_hours < 1) {
retention_hours = 168
}
if (upload_window_hours < 1 || upload_window_hours > retention_hours) {
upload_window_hours = (retention_hours < 2) ? retention_hours : 2
}
if (lease_refresh_interval < 1) {
lease_refresh_interval = 60
}
if (identity_cache_ttl < 1) {
identity_cache_ttl = 60
}
if (identity_cache_ttl > lease_refresh_interval) {
identity_cache_ttl = lease_refresh_interval
}
if (provisional_retention_hours < 1) {
provisional_retention_hours = 6
}
if (provisional_retention_hours > retention_hours) {
provisional_retention_hours = retention_hours
}
if (persistent_interval < 60) {
persistent_interval = 3600
}
if (persistent_retry_interval < 60) {
persistent_retry_interval = 300
}
if (persistent_compact_interval < 3600) {
persistent_compact_interval = 604800
}
if (snapshot_seq_reservation_size < 64) {
snapshot_seq_reservation_size = 4096
}
if (max_devices < 1) {
max_devices = 128
}
tmp_state_updated_at = state_file_updated_at(state_file)
persistent_state_updated_at = state_file_updated_at(persistent_state_file)
persistent_journal_updated_at = journal_file_updated_at(persistent_journal_file)
persistent_combined_updated_at = persistent_state_updated_at
if (persistent_journal_updated_at > persistent_combined_updated_at) {
persistent_combined_updated_at = persistent_journal_updated_at
}
tmp_state_snapshot_seq = (tmp_state_updated_at >= 0) ? state_file_snapshot_seq(state_file) : -1
persistent_state_snapshot_seq = (persistent_state_updated_at >= 0) ? state_file_snapshot_seq(persistent_state_file) : -1
persistent_journal_snapshot_seq = (persistent_journal_updated_at >= 0) ? journal_file_snapshot_seq(persistent_journal_file) : -1
tmp_state_generation_id = (tmp_state_updated_at >= 0) ? state_file_generation_id(state_file) : ""
persistent_state_generation_id = (persistent_state_updated_at >= 0) ? state_file_generation_id(persistent_state_file) : ""
persistent_journal_generation_id = (persistent_journal_updated_at >= 0) ? journal_file_generation_id(persistent_journal_file) : ""
persistent_combined_snapshot_seq = persistent_state_snapshot_seq
persistent_combined_generation_id = persistent_state_generation_id
if (persistent_journal_snapshot_seq > persistent_combined_snapshot_seq) {
persistent_combined_snapshot_seq = persistent_journal_snapshot_seq
persistent_combined_generation_id = persistent_journal_generation_id
}
else if (persistent_combined_generation_id == "" && persistent_journal_generation_id != "") {
persistent_combined_generation_id = persistent_journal_generation_id
}
# snapshot_seq is meaningful only inside one generation. A live tmpfs state
# from a different generation must not lose to a numerically larger sequence
# left by an older flash generation. Within the same generation, sequence is
# the primary freshness signal so NTP clock corrections cannot reorder state.
prefer_persistent_state = 0
if (tmp_state_updated_at >= 0 && persistent_combined_updated_at >= 0 && \
tmp_state_generation_id != "" && persistent_combined_generation_id != "" && \
tmp_state_generation_id != persistent_combined_generation_id) {
prefer_persistent_state = 0
}
else if (tmp_state_snapshot_seq >= 0 || persistent_combined_snapshot_seq >= 0) {
prefer_persistent_state = (persistent_combined_snapshot_seq > tmp_state_snapshot_seq)
}
else {
prefer_persistent_state = (persistent_combined_updated_at > tmp_state_updated_at)
}
if (prefer_persistent_state && persistent_combined_updated_at >= 0) {
loaded_from_persistent = 1
if (persistent_state_updated_at >= 0) {
load_state_file(persistent_state_file)
}
if (persistent_journal_updated_at >= 0) {
load_journal_file(persistent_journal_file, persistent_state_updated_at, snapshot_seq)
}
}
else if (tmp_state_updated_at >= 0) {
load_state_file(state_file)
}
else if (persistent_combined_updated_at >= 0) {
loaded_from_persistent = 1
if (persistent_state_updated_at >= 0) {
load_state_file(persistent_state_file)
}
if (persistent_journal_updated_at >= 0) {
load_journal_file(persistent_journal_file, persistent_state_updated_at, snapshot_seq)
}
}
if (persistent_state_file == "" && persistent_journal_file == "") {
persistent_updated_at = 0
persistent_compacted_at = 0
last_journal_completed_bucket = 0
persistence_healthy = 1
persistent_error_count = 0
persistent_last_error_at = 0
}
# started_at identifies the lifetime of the currently retained statistics
# dataset. It survives collector restarts whenever state can be restored.
if (started_at <= 0) {
started_at = current_time()
}
# generation_id is the stable identity for that dataset. The stats daemon
# supplies a fresh candidate on every process start, but restored state wins.
if (generation_id == "") {
generation_id = (generation_seed != "") ? generation_seed : sprintf("legacy-%d", started_at)
}
durable_snapshot_seq_reserved = load_sequence_reservation(sequence_file, generation_id)
if (durable_snapshot_seq_reserved > snapshot_seq_reserved_through) {
snapshot_seq_reserved_through = durable_snapshot_seq_reserved
}
# A tmpfs restart can continue with the exact last emitted sequence because
# save_state_file() precedes save_json(). After a reboot restores flash-backed
# state, skip the whole durable reservation so a sequence already observed by
# Hub can never be reused even when the last flash checkpoint was older.
if (loaded_from_persistent && snapshot_seq_reserved_through > snapshot_seq) {
snapshot_seq = snapshot_seq_reserved_through
}
# session_started_at is process-scoped and intentionally changes every time
# the collector starts, even when generation_id and started_at are restored.
session_started_at = current_time()
migrate_legacy_device_totals(current_time())
refresh_leases(current_time(), 1)
last_snapshot = updated_at
if (last_snapshot <= 0) {
last_snapshot = session_started_at
}
if (persistent_state_file != "" || persistent_journal_file != "") {
if (last_journal_completed_bucket <= 0 && persistent_updated_at > 0) {
last_journal_completed_bucket = hour_start(persistent_updated_at) - 3600
}
if (persistent_compacted_at <= 0) {
persistent_compacted_at = (persistent_updated_at > 0) ? persistent_updated_at : current_time()
}
schedule_next_persistent(current_time())
schedule_next_compaction(current_time())
}
else {
next_persistent_at = 0
next_compact_at = 0
}
save_snapshot(current_time(), 0, 1)
}
$1 == "snapshot" && NF >= 9 {
clear_poll_clients()
poll_active = 1
poll_instance_id = $2
poll_transport_scope = $3
poll_client_capacity = numeric($4, 0)
poll_tracked_clients = numeric($5, 0)
poll_untracked_queries = numeric($6, 0)
poll_untracked_blocked = numeric($7, 0)
poll_total_queries = numeric($8, 0)
poll_total_blocked = numeric($9, 0)
next
}
$1 == "client" && NF >= 4 && poll_active {
poll_client_seen[$2] = 1
poll_client_queries[$2] = numeric($3, 0)
poll_client_blocked[$2] = numeric($4, 0)
next
}
$1 == "commit" && poll_active {
apply_source_snapshot()
next
}
$1 == "error" {
poll_active = 0
clear_poll_clients()
record_source_error()
next
}
/dnsmasq\[[0-9]+\]:/ {
if ($0 ~ / query\[[^]]+\] .* from /) {
record_event(1, 0, extract_query_client($0))
next
}
if ($0 ~ / config .* is (0\.0\.0\.0|::)([[:space:]]|$)/) {
record_event(0, 1, extract_extra_client($0))
next
}
}
END {
save_snapshot(current_time(), 1, 0)
}
@@ -0,0 +1,92 @@
'use strict';
let fs = require('fs');
function empty_obj() {
return {
data: {},
warnings: [],
errors: []
};
}
function read_json(path) {
let raw = fs.readfile(path);
if (!raw)
return empty_obj();
try {
let obj = json(raw);
if (!obj || type(obj) != 'object')
obj = {};
if (!obj.data || type(obj.data) != 'object')
obj.data = {};
if (!obj.warnings || type(obj.warnings) != 'array')
obj.warnings = [];
if (!obj.errors || type(obj.errors) != 'array')
obj.errors = [];
return obj;
}
catch (e) {
return empty_obj();
}
}
function usage() {
print('usage: ucode status-store.uc <path> <command> [args...]\n');
return 1;
}
let path = ARGV[0];
let cmd = ARGV[1];
if (!path || !cmd)
exit(usage());
let obj = read_json(path);
if (cmd == 'reset') {
obj = empty_obj();
}
else if (cmd == 'set') {
let key = ARGV[2];
let value = ARGV[3];
if (!key)
exit(2);
obj.data[key] = value;
}
else if (cmd == 'add_error') {
let code = ARGV[2];
if (!code)
exit(2);
push(obj.errors, { code: code });
}
else if (cmd == 'add_warning') {
let code = ARGV[2];
if (!code)
exit(2);
push(obj.warnings, { code: code });
}
else if (cmd == 'clear_messages') {
obj.errors = [];
obj.warnings = [];
}
else if (cmd == 'dump') {
}
else {
exit(usage());
}
print(sprintf('%J\n', obj));
exit(0);
+302
View File
@@ -0,0 +1,302 @@
# shellcheck shell=ash
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
readonly SS_STATUS_STORE='/usr/lib/safeshield/status-store.uc'
ss_status_exec() {
ucode "${SS_STATUS_STORE}" "${RUNNING_STATUS_FILE}" "$@"
}
ss_status_apply() {
local tmp="${RUNNING_STATUS_FILE}.tmp"
mkdir -p "${RUNNING_STATUS_FILE%/*}" || return 1
ss_status_exec "$@" >"${tmp}" || {
rm -f "${tmp}"
return 1
}
[ -s "${tmp}" ] || {
rm -f "${tmp}"
return 1
}
mv -f "${tmp}" "${RUNNING_STATUS_FILE}" || {
rm -f "${tmp}"
return 1
}
return 0
}
ss_now() {
date +%s
}
ss_status_set_now() {
ss_status_set "$1" "$(ss_now)"
}
ss_status_reset_health_fields() {
ss_status_set health_dnsmasq_binary ""
ss_status_set health_dnsmasq_version ""
ss_status_set health_dnsmasq_features ""
ss_status_set health_dnsmasq_confdir ""
ss_status_set health_dnsmasq_initial_restart ""
ss_status_set health_dnsmasq_final_restart ""
ss_status_set health_dns_runtime ""
ss_status_set health_blocklist_verify ""
ss_status_set health_min_valid_line_count ""
ss_status_set health_max_file_size ""
ss_status_set health_api_resolve ""
ss_status_set health_artifact_download ""
ss_status_set health_artifact_sha256 ""
ss_status_set dnsmasq_version ""
ss_status_set dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION:-2.93}"
}
ss_status_reset_blocklist_fields() {
ss_status_set blocklist_installed "0"
ss_status_set blocklist_file_size_kb "0"
ss_status_set blocklist_verification_ok "0"
ss_status_set blocklist_test_domain ""
ss_status_set blocklist_test_domain_sample_count "0"
ss_status_set blocklist_test_domain_success_count "0"
ss_status_set blocklist_test_domains ""
ss_status_set blocklist_backup_available "0"
}
ss_status_reset_artifact_fields() {
ss_status_set license_plan ""
ss_status_set license_status ""
ss_status_set physical_fingerprint ""
ss_status_set fingerprint_version ""
ss_status_set identity_provider ""
ss_status_set identity_source ""
ss_status_set identity_strength ""
ss_status_set identity_profile ""
ss_status_set device_code ""
ss_status_set device_code_source ""
ss_status_set installation_id ""
ss_status_set device_profile ""
ss_status_set artifact_tier ""
ss_status_set artifact_version ""
ss_status_set artifact_sha256 ""
ss_status_set artifact_unique_domains "0"
ss_status_set artifact_rules "0"
ss_status_set artifact_download_url_present "0"
ss_status_set artifact_source_count "0"
ss_status_set artifact_block_source_count "0"
ss_status_set artifact_allow_source_count "0"
}
ss_status_mark_failure() {
local code="$1"
ss_status_set status "error"
ss_status_set_now last_failure
ss_status_set last_result "error"
ss_status_set last_error_code "$code"
ss_status_add_error "$code"
}
ss_status_mark_success() {
local interval="${1:-0}"
local now
now="$(ss_now)"
ss_status_apply clear_messages >/dev/null 2>&1 || true
ss_status_set status "ready"
ss_status_set stage "done"
ss_status_set last_success "$now"
ss_status_set last_result "success"
ss_status_set last_error_code ""
if is_valid_integer "$interval" && [ "$interval" -gt 0 ] 2>/dev/null; then
ss_status_set next_refresh_at "$((now + interval))"
fi
}
ss_status_prepare_refresh() {
local last_success last_local_apply last_local_apply_failure
last_success="$(json get last_success 2>/dev/null)"
last_local_apply="$(json get last_local_apply 2>/dev/null)"
last_local_apply_failure="$(json get last_local_apply_failure 2>/dev/null)"
is_valid_integer "$last_success" || last_success=0
is_valid_integer "$last_local_apply" || last_local_apply=0
is_valid_integer "$last_local_apply_failure" || last_local_apply_failure=0
ss_status_reset
ss_status_set last_success "$last_success"
ss_status_set last_local_apply "$last_local_apply"
ss_status_set last_local_apply_failure "$last_local_apply_failure"
}
ss_status_set() {
local key="$1"
local value="$2"
[ -n "$key" ] || return 0
ss_status_apply set "$key" "$value" >/dev/null 2>&1 || true
}
ss_status_add_error() {
local code="$1"
[ -n "$code" ] || return 0
ss_status_apply add_error "$code" >/dev/null 2>&1 || true
}
ss_status_add_warning() {
local code="$1"
[ -n "$code" ] || return 0
ss_status_apply add_warning "$code" >/dev/null 2>&1 || true
}
ss_status_reset() {
rm -f "${RUNNING_STATUS_FILE}" "${RUNNING_STATUS_FILE}.tmp"
ss_status_apply reset >/dev/null 2>&1 || return 1
ss_status_set status "idle"
ss_status_set stage ""
ss_status_set valid_line_count "0"
ss_status_set last_result "idle"
ss_status_set last_error_code ""
ss_status_set last_attempt "0"
ss_status_set last_success "0"
ss_status_set last_failure "0"
ss_status_set last_local_apply "0"
ss_status_set last_local_apply_failure "0"
ss_status_set next_refresh_at "0"
ss_status_reset_health_fields
ss_status_reset_blocklist_fields
ss_status_reset_artifact_fields
ss_status_apply clear_messages >/dev/null 2>&1 || true
}
ss_blocklist_tmp_path() {
printf '%s/.safeshield.blocklist.tmp.%s' "${SS_DNSMASQ_DIR}" "$$"
}
ss_backup_tmp_path() {
printf '%s.tmp.%s' "${SS_PREV_BLOCKLIST_GZ}" "$$"
}
ss_sync_path() {
local path="$1"
[ -n "$path" ] || return 0
sync -f "$path" 2>/dev/null && return 0
sync "$path" 2>/dev/null && return 0
sync 2>/dev/null || true
}
ss_install_blocklist_atomic() {
local src="$1"
local dst="$2"
local dir
[ -f "$src" ] || return 1
[ -n "$dst" ] || return 1
dir="${dst%/*}"
ss_sync_path "$src"
mv -f "$src" "$dst" || return 1
ss_sync_path "$dir"
return 0
}
ss_export_existing_blocklist() {
local tmp
[ -f "${SS_BLOCKLIST_FILE}" ] || return 1
command_exists gzip || return 1
tmp="$(ss_backup_tmp_path)" || return 1
gzip -c "${SS_BLOCKLIST_FILE}" >"$tmp" || {
rm -f "$tmp"
return 1
}
ss_sync_path "$tmp"
mv -f "$tmp" "${SS_PREV_BLOCKLIST_GZ}" || {
rm -f "$tmp"
return 1
}
ss_sync_path "${SS_PREV_BLOCKLIST_GZ%/*}"
return 0
}
ss_restore_previous_blocklist() {
local tmp
[ -f "${SS_PREV_BLOCKLIST_GZ}" ] || return 1
command_exists gunzip || return 1
tmp="$(ss_blocklist_tmp_path)" || return 1
gunzip -c "${SS_PREV_BLOCKLIST_GZ}" >"$tmp" || {
rm -f "$tmp"
return 1
}
ss_install_blocklist_atomic "$tmp" "${SS_BLOCKLIST_FILE}" || {
rm -f "$tmp"
return 1
}
return 0
}
ss_refresh_lock_open() {
mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1
eval "exec ${SS_REFRESH_LOCK_FD}>\"${SS_REFRESH_LOCK}\"" || return 1
flock -n "${SS_REFRESH_LOCK_FD}" || {
eval "exec ${SS_REFRESH_LOCK_FD}>&-"
return 1
}
}
ss_refresh_lock_open_wait() {
local timeout="${1:-120}"
local waited=0
is_valid_integer "$timeout" || timeout=120
mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1
eval "exec ${SS_REFRESH_LOCK_FD}>\"${SS_REFRESH_LOCK}\"" || return 1
while ! flock -n "${SS_REFRESH_LOCK_FD}"; do
if [ "$waited" -ge "$timeout" ] 2>/dev/null; then
eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true
return 1
fi
ss_should_stop && {
eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true
return 130
}
sleep 1
waited=$((waited + 1))
done
return 0
}
ss_refresh_lock_close() {
flock -u "${SS_REFRESH_LOCK_FD}" 2>/dev/null || true
eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true
}
+137
View File
@@ -0,0 +1,137 @@
# shellcheck shell=ash
readonly LOCK_FD=309
readonly RUNNING_STATUS_LOCK="/var/lock/${PKG_NAME}.lock"
readonly RUNNING_STATUS_FILE="/dev/shm/${PKG_NAME}.status.json"
command_exists() {
command -v "$1" >/dev/null 2>&1
}
ss_mask_secret() {
local value="$1"
local len
[ -n "$value" ] || {
printf '%s' ''
return 0
}
len="${#value}"
if [ "$len" -le 8 ] 2>/dev/null; then
printf '%s' '********'
else
printf '%s****%s' "${value%${value#????}}" "${value#${value%????}}"
fi
}
is_valid_integer() {
case "$1" in
'' | *[!0-9]*) return 1 ;;
esac
[ "$1" -ge 0 ] 2>/dev/null
}
is_greater_equal() {
[ "$#" -eq 2 ] || return 2
[ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ]
}
json_lock_open() {
mkdir -p "${RUNNING_STATUS_LOCK%/*}" || return 1
eval "exec ${LOCK_FD}>\"${RUNNING_STATUS_LOCK}\"" || return 1
flock -x "${LOCK_FD}" || {
eval "exec ${LOCK_FD}>&-"
return 1
}
}
json_lock_close() {
flock -u "${LOCK_FD}" 2>/dev/null || true
eval "exec ${LOCK_FD}>&-" 2>/dev/null || true
}
_json_init_data_object() {
mkdir -p "${RUNNING_STATUS_FILE%/*}" || return 1
if ! json_load_file "$RUNNING_STATUS_FILE" 2>/dev/null; then
json_init
fi
json_select data 2>/dev/null || {
json_add_object data
json_close_object
json_select data 2>/dev/null
}
}
_json_get() {
local key="$1"
local out=''
json_get_var out "$key" 2>/dev/null || true
printf '%s' "$out"
}
_json_set() {
local key="$1"
local value="$2"
json_add_string "$key" "$value"
json_select .. 2>/dev/null || true
json_dump >"${RUNNING_STATUS_FILE}"
}
_json_add_message() {
local kind="$1"
local value="$2"
local arr="${kind}s"
json_select .. 2>/dev/null || true
json_select "$arr" 2>/dev/null || {
json_add_array "$arr"
json_close_array
json_select "$arr" 2>/dev/null || return 1
}
json_add_object ""
json_add_string code "$value"
json_add_string info "$value"
json_close_object
json_select .. 2>/dev/null || true
json_dump >"${RUNNING_STATUS_FILE}"
}
json() {
local action="$1"
local key="$2"
shift 2 2>/dev/null
local value="$*"
local rc=0
json_lock_open || return 1
_json_init_data_object || rc=1
if [ "$rc" -eq 0 ]; then
case "${action}:${key}" in
get:*)
_json_get "$key" || rc=1
;;
set:*)
_json_set "$key" "$value" || rc=1
;;
add:error | add:warning)
_json_add_message "$key" "$value" || rc=1
;;
*)
rc=1
;;
esac
fi
json_cleanup 2>/dev/null || true
json_lock_close
return "$rc"
}
+258
View File
@@ -0,0 +1,258 @@
#!/bin/sh
# shellcheck shell=ash
# Variables below are populated by sourced helpers and ss_load_config()
# shellcheck disable=SC2154
LC_ALL=C
IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT
# shellcheck disable=SC1091
. /lib/functions.sh
# shellcheck disable=SC1091
. /usr/lib/safeshield/core.sh
readonly SS_REFRESHD_LOCK='/var/lock/safeshield-refreshd.lock'
readonly SS_REFRESHD_LOCK_FD=308
ss_should_terminate=0
ss_sleep_pid=''
refreshd_lock() {
mkdir -p "${SS_REFRESHD_LOCK%/*}" || return 1
eval "exec ${SS_REFRESHD_LOCK_FD}>\"${SS_REFRESHD_LOCK}\"" || return 1
flock -n "${SS_REFRESHD_LOCK_FD}" || return 1
}
refreshd_unlock() {
flock -u "${SS_REFRESHD_LOCK_FD}" 2>/dev/null || true
eval "exec ${SS_REFRESHD_LOCK_FD}>&-" 2>/dev/null || true
}
handle_term() {
ss_should_terminate=1
if [ -n "$ss_sleep_pid" ]; then
kill "$ss_sleep_pid" 2>/dev/null || true
fi
}
sleep_seconds() {
local seconds="$1"
local rc=0
[ "$ss_should_terminate" -eq 1 ] && return 1
[ "$seconds" -gt 0 ] 2>/dev/null || return 0
sleep "$seconds" &
ss_sleep_pid=$!
wait "$ss_sleep_pid" 2>/dev/null || rc=$?
ss_sleep_pid=''
[ "$ss_should_terminate" -eq 1 ] && return 1
[ "$rc" -eq 0 ]
}
wait_for_wan_if_needed() {
local require_wan
require_wan="$(ss_config_get config require_wan 1)"
case "$require_wan" in
1)
ubus -t 30 wait_for network.interface.wan 2>/dev/null || true
;;
esac
}
status_epoch() {
local key="$1"
local value
value="$(json get "$key" 2>/dev/null)"
is_valid_integer "$value" || value=0
printf '%s\n' "$value"
}
schedule_base_epoch() {
local now="$1"
local last_success last_attempt base=0
last_success="$(status_epoch last_success)"
last_attempt="$(status_epoch last_attempt)"
if [ "$last_success" -gt 0 ] 2>/dev/null && [ "$last_success" -le "$now" ] 2>/dev/null; then
base="$last_success"
fi
# A failed or currently-running refresh must also move the retry window
# forward so refreshd cannot enter an immediate retry loop. A successful
# refresh is naturally newer than its attempt timestamp and wins here.
if [ "$last_attempt" -gt "$base" ] 2>/dev/null && [ "$last_attempt" -le "$now" ] 2>/dev/null; then
base="$last_attempt"
fi
printf '%s\n' "$base"
}
has_recent_success() {
local interval="$1"
local now last_success age
now="$(date +%s)"
last_success="$(status_epoch last_success)"
[ "$last_success" -gt 0 ] 2>/dev/null || return 1
[ "$last_success" -le "$now" ] 2>/dev/null || return 1
age=$((now - last_success))
[ "$age" -lt "$interval" ] 2>/dev/null
}
set_next_refresh_epoch() {
local epoch="$1"
is_valid_integer "$epoch" || epoch=0
ss_status_set next_refresh_at "$epoch"
}
clear_next_refresh_at() {
ss_status_set next_refresh_at "0"
}
wait_until_refresh_due() {
local interval="$1"
local fallback_epoch="$2"
local now base due remaining
while :; do
[ "$ss_should_terminate" -eq 1 ] && return 1
now="$(date +%s)"
base="$(schedule_base_epoch "$now")"
if [ "$base" -le 0 ] 2>/dev/null; then
base="$fallback_epoch"
fi
due=$((base + interval))
remaining=$((due - now))
if [ "$remaining" -le 0 ] 2>/dev/null; then
clear_next_refresh_at
return 0
fi
set_next_refresh_epoch "$due"
sleep_seconds "$remaining" || return 1
# A manual refresh may have completed while refreshd was sleeping.
# Recalculate from the latest last_success/last_attempt before running.
done
}
main() {
local interval
local boot_delay
local refresh_on_boot
local schedule_anchor
refreshd_lock || exit 0
ss_load_config || exit 1
if [ ! -s "${RUNNING_STATUS_FILE}" ]; then
ss_status_reset || true
fi
ss_sync_blocklist_status
ss_status_set status "idle"
ss_status_set stage "starting"
interval="$(ss_config_get config refresh_interval_s 28800)"
boot_delay="$(ss_config_get config boot_start_delay_s 30)"
refresh_on_boot="$(ss_config_get config refresh_on_boot 1)"
is_valid_integer "$interval" || interval=28800
[ "$interval" -gt 0 ] 2>/dev/null || interval=28800
is_valid_integer "$boot_delay" || boot_delay=30
wait_for_wan_if_needed
ss_status_set stage "boot_delay"
set_next_refresh_epoch "$(($(date +%s) + boot_delay))"
sleep_seconds "$boot_delay" || exit 0
if [ "$refresh_on_boot" = "1" ] && [ "${ss_enabled}" = "1" ]; then
if has_recent_success "$interval"; then
log_info "Skipping boot refresh because a recent refresh already succeeded"
ss_status_set status "idle"
ss_status_set stage "boot_refresh_skipped"
else
ss_status_set status "running"
ss_status_set stage "boot_refresh"
clear_next_refresh_at
safeshield_force_download
fi
fi
schedule_anchor="$(date +%s)"
while :; do
ss_load_config || {
schedule_anchor="$(date +%s)"
sleep_seconds 5 || break
continue
}
interval="$(ss_config_get config refresh_interval_s 28800)"
is_valid_integer "$interval" || interval=28800
[ "$interval" -gt 0 ] 2>/dev/null || interval=28800
if [ "${ss_enabled}" != "1" ]; then
ss_status_set status "disabled"
ss_status_set stage "disabled"
clear_next_refresh_at
sleep_seconds "$interval" || break
schedule_anchor="$(date +%s)"
continue
fi
ss_status_set status "idle"
ss_status_set stage "scheduled_wait"
wait_until_refresh_due "$interval" "$schedule_anchor" || break
ss_load_config || {
schedule_anchor="$(date +%s)"
continue
}
if [ "${ss_enabled}" != "1" ]; then
ss_status_set status "disabled"
ss_status_set stage "disabled"
clear_next_refresh_at
schedule_anchor="$(date +%s)"
continue
fi
wait_for_wan_if_needed || true
ss_status_set status "running"
ss_status_set stage "scheduled_refresh"
clear_next_refresh_at
safeshield_force_download
schedule_anchor="$(date +%s)"
done
ss_load_config >/dev/null 2>&1 || true
if [ "${ss_enabled:-0}" = "1" ]; then
ss_status_set status "idle"
ss_status_set stage "stopped"
else
ss_status_set status "disabled"
ss_status_set stage "disabled"
fi
clear_next_refresh_at
}
trap "handle_term" TERM INT
trap "refreshd_unlock; clear_next_refresh_at" EXIT
main "$@"
+496
View File
@@ -0,0 +1,496 @@
#!/bin/sh
# shellcheck shell=ash
# Variables are populated by sourced SafeShield helpers and ss_load_config().
# shellcheck disable=SC2154
LC_ALL=C
export LC_ALL
umask 077
SS_STATS_UPLOAD_FUNCTIONS_LIB="${SS_STATS_UPLOAD_FUNCTIONS_LIB:-/lib/functions.sh}"
SS_STATS_UPLOAD_CORE_LIB="${SS_STATS_UPLOAD_CORE_LIB:-/usr/lib/safeshield/core.sh}"
SS_STATS_UPLOAD_INTERVAL_S="${SS_STATS_UPLOAD_INTERVAL_S:-1800}"
SS_STATS_UPLOAD_IDLE_WAIT_S="${SS_STATS_UPLOAD_IDLE_WAIT_S:-15}"
SS_STATS_UPLOAD_RETRY_DELAY_S="${SS_STATS_UPLOAD_RETRY_DELAY_S:-2}"
SS_STATS_UPLOAD_FULL_EVERY="${SS_STATS_UPLOAD_FULL_EVERY:-23}"
SS_STATS_UPLOAD_DENIED_RECHECK_S="${SS_STATS_UPLOAD_DENIED_RECHECK_S:-43200}"
SS_STATS_UPLOAD_RESPONSE_FILE="${SS_STATS_UPLOAD_RESPONSE_FILE:-/tmp/safeshield/statistics/upload.response.json}"
# shellcheck disable=SC1090
. "$SS_STATS_UPLOAD_FUNCTIONS_LIB"
# shellcheck disable=SC1090
. "$SS_STATS_UPLOAD_CORE_LIB"
ss_should_terminate=0
upload_sleep_pid=''
upload_state_generation=''
upload_state_last_success_seq=0
upload_state_last_full_seq=0
upload_state_recent_successes=0
upload_state_needs_full=1
upload_pending_mode=''
upload_pending_generation=''
upload_pending_seq=0
upload_handle_term() {
ss_should_terminate=1
if [ -n "$upload_sleep_pid" ]; then
kill "$upload_sleep_pid" 2>/dev/null || true
fi
}
upload_sleep() {
local seconds="$1"
local rc=0
[ "$ss_should_terminate" -eq 1 ] && return 1
[ "$seconds" -gt 0 ] 2>/dev/null || return 0
sleep "$seconds" &
upload_sleep_pid=$!
wait "$upload_sleep_pid" 2>/dev/null || rc=$?
upload_sleep_pid=''
[ "$ss_should_terminate" -eq 1 ] && return 1
[ "$rc" -eq 0 ]
}
upload_state_reset() {
upload_state_generation=''
upload_state_last_success_seq=0
upload_state_last_full_seq=0
upload_state_recent_successes=0
upload_state_needs_full=1
}
upload_state_load() {
local old_ifs
upload_state_reset
[ -s "$SS_STATISTICS_UPLOAD_STATE_FILE" ] || return 0
old_ifs="$IFS"
IFS="$(printf '\t')"
read -r upload_state_generation upload_state_last_success_seq upload_state_last_full_seq upload_state_recent_successes upload_state_needs_full <"$SS_STATISTICS_UPLOAD_STATE_FILE" || true
IFS="$old_ifs"
is_valid_integer "$upload_state_last_success_seq" || upload_state_last_success_seq=0
is_valid_integer "$upload_state_last_full_seq" || upload_state_last_full_seq=0
is_valid_integer "$upload_state_recent_successes" || upload_state_recent_successes=0
case "$upload_state_needs_full" in
0 | 1) ;;
*) upload_state_needs_full=1 ;;
esac
}
upload_state_save() {
local tmp="${SS_STATISTICS_UPLOAD_STATE_FILE}.tmp.$$"
mkdir -p "${SS_STATISTICS_UPLOAD_STATE_FILE%/*}" || return 1
printf '%s\t%s\t%s\t%s\t%s\n' \
"$upload_state_generation" \
"$upload_state_last_success_seq" \
"$upload_state_last_full_seq" \
"$upload_state_recent_successes" \
"$upload_state_needs_full" >"$tmp" || {
rm -f "$tmp"
return 1
}
mv -f "$tmp" "$SS_STATISTICS_UPLOAD_STATE_FILE" || {
rm -f "$tmp"
return 1
}
}
upload_mark_needs_full() {
local generation="${1:-$upload_state_generation}"
if [ -n "$generation" ] && [ "$upload_state_generation" != "$generation" ]; then
upload_state_generation="$generation"
upload_state_last_success_seq=0
upload_state_last_full_seq=0
upload_state_recent_successes=0
fi
upload_state_needs_full=1
upload_state_save >/dev/null 2>&1 || true
}
upload_pending_clear() {
rm -f "$SS_STATISTICS_UPLOAD_PENDING_FILE" "$SS_STATISTICS_UPLOAD_PENDING_META_FILE"
upload_pending_mode=''
upload_pending_generation=''
upload_pending_seq=0
}
upload_pending_load() {
local old_ifs payload_generation payload_seq
upload_pending_mode=''
upload_pending_generation=''
upload_pending_seq=0
[ -s "$SS_STATISTICS_UPLOAD_PENDING_FILE" ] || return 1
[ -s "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" ] || {
upload_pending_clear
return 1
}
old_ifs="$IFS"
IFS="$(printf '\t')"
read -r upload_pending_mode upload_pending_generation upload_pending_seq <"$SS_STATISTICS_UPLOAD_PENDING_META_FILE" || true
IFS="$old_ifs"
case "$upload_pending_mode" in
full | recent) ;;
*)
upload_pending_clear
return 1
;;
esac
is_valid_integer "$upload_pending_seq" || {
upload_pending_clear
return 1
}
[ "$upload_pending_seq" -gt 0 ] 2>/dev/null || {
upload_pending_clear
return 1
}
payload_generation="$(ss_json_get_file "$SS_STATISTICS_UPLOAD_PENDING_FILE" '@.generation_id')"
payload_seq="$(ss_json_get_file "$SS_STATISTICS_UPLOAD_PENDING_FILE" '@.snapshot_seq')"
if [ "$payload_generation" != "$upload_pending_generation" ] || [ "$payload_seq" != "$upload_pending_seq" ]; then
upload_pending_clear
return 1
fi
return 0
}
upload_source_is_v3() {
local file="$1"
local schema_name schema_version seq generation
[ -s "$file" ] || return 1
schema_name="$(ss_json_get_file "$file" '@.schema.name')"
schema_version="$(ss_json_get_file "$file" '@.schema.version')"
generation="$(ss_json_get_file "$file" '@.generation_id')"
seq="$(ss_json_get_file "$file" '@.snapshot_seq')"
[ "$schema_name" = 'safeshield.statistics' ] || return 1
[ "$schema_version" = '3' ] || return 1
[ -n "$generation" ] || return 1
is_valid_integer "$seq" || return 1
[ "$seq" -gt 0 ] 2>/dev/null
}
upload_pending_create() {
local source mode source_generation source_seq full_generation full_seq
local tmp_payload tmp_meta
upload_state_load
upload_source_is_v3 "$SS_STATISTICS_JSON_FILE" || return 1
full_generation="$(ss_json_get_file "$SS_STATISTICS_JSON_FILE" '@.generation_id')"
full_seq="$(ss_json_get_file "$SS_STATISTICS_JSON_FILE" '@.snapshot_seq')"
if [ "$upload_state_generation" != "$full_generation" ]; then
upload_state_generation="$full_generation"
upload_state_last_success_seq=0
upload_state_last_full_seq=0
upload_state_recent_successes=0
upload_state_needs_full=1
upload_state_save >/dev/null 2>&1 || true
fi
mode='recent'
source="$SS_STATISTICS_UPLOAD_JSON_FILE"
if [ "$upload_state_needs_full" = '1' ] ||
[ "$upload_state_last_full_seq" -le 0 ] 2>/dev/null ||
[ "$upload_state_recent_successes" -ge "$SS_STATS_UPLOAD_FULL_EVERY" ] 2>/dev/null; then
mode='full'
source="$SS_STATISTICS_JSON_FILE"
elif ! upload_source_is_v3 "$source"; then
mode='full'
source="$SS_STATISTICS_JSON_FILE"
fi
tmp_payload="${SS_STATISTICS_UPLOAD_PENDING_FILE}.tmp.$$"
tmp_meta="${SS_STATISTICS_UPLOAD_PENDING_META_FILE}.tmp.$$"
mkdir -p "${SS_STATISTICS_UPLOAD_PENDING_FILE%/*}" || return 1
cp "$source" "$tmp_payload" || {
rm -f "$tmp_payload"
return 1
}
upload_source_is_v3 "$tmp_payload" || {
rm -f "$tmp_payload"
return 1
}
source_generation="$(ss_json_get_file "$tmp_payload" '@.generation_id')"
source_seq="$(ss_json_get_file "$tmp_payload" '@.snapshot_seq')"
if [ "$source_generation" != "$full_generation" ]; then
rm -f "$tmp_payload"
return 1
fi
if [ "$mode" = 'recent' ] && [ "$source_seq" != "$full_seq" ]; then
# The collector publishes the full snapshot before its recent projection.
# Avoid promoting a previous projection while those two atomic writes cross.
rm -f "$tmp_payload"
return 1
fi
if [ "$source_seq" -le "$upload_state_last_success_seq" ] 2>/dev/null; then
rm -f "$tmp_payload"
return 2
fi
printf '%s\t%s\t%s\n' "$mode" "$source_generation" "$source_seq" >"$tmp_meta" || {
rm -f "$tmp_payload" "$tmp_meta"
return 1
}
mv -f "$tmp_payload" "$SS_STATISTICS_UPLOAD_PENDING_FILE" || {
rm -f "$tmp_payload" "$tmp_meta"
return 1
}
mv -f "$tmp_meta" "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" || {
rm -f "$tmp_meta"
upload_pending_clear
return 1
}
upload_pending_mode="$mode"
upload_pending_generation="$source_generation"
upload_pending_seq="$source_seq"
return 0
}
upload_ensure_credentials() {
ss_statistics_upload_denied && return 2
ss_statistics_load_upload_credentials && return 0
log_info 'Refreshing Hub statistics upload credentials'
ss_statistics_refresh_upload_credentials || return $?
ss_statistics_load_upload_credentials
}
upload_response_valid() {
local response="$1"
local status generation server_seq
[ -s "$response" ] || return 1
status="$(ss_json_get_file "$response" '@.status')"
generation="$(ss_json_get_file "$response" '@.generation_id')"
server_seq="$(ss_json_get_file "$response" '@.last_snapshot_seq')"
case "$status" in
applied | duplicate | stale) ;;
*) return 1 ;;
esac
[ "$generation" = "$upload_pending_generation" ] || return 1
is_valid_integer "$server_seq" || return 1
[ "$server_seq" -ge "$upload_pending_seq" ] 2>/dev/null || return 1
SS_STATS_UPLOAD_RESPONSE_STATUS="$status"
SS_STATS_UPLOAD_RESPONSE_SEQ="$server_seq"
return 0
}
upload_record_success() {
local result_status="$1"
local server_seq="$2"
upload_state_load
if [ "$upload_state_generation" != "$upload_pending_generation" ]; then
upload_state_generation="$upload_pending_generation"
upload_state_last_success_seq=0
upload_state_last_full_seq=0
upload_state_recent_successes=0
upload_state_needs_full=1
fi
if [ "$server_seq" -gt "$upload_state_last_success_seq" ] 2>/dev/null; then
upload_state_last_success_seq="$server_seq"
fi
if [ "$upload_pending_mode" = 'full' ] && [ "$result_status" != 'stale' ]; then
upload_state_last_full_seq="$upload_pending_seq"
upload_state_recent_successes=0
upload_state_needs_full=0
else
if [ "$result_status" = 'stale' ]; then
upload_state_needs_full=1
elif [ "$upload_state_needs_full" = '0' ]; then
upload_state_recent_successes=$((upload_state_recent_successes + 1))
fi
fi
upload_state_save >/dev/null 2>&1 || true
}
upload_record_failure() {
upload_mark_needs_full "$upload_pending_generation"
}
upload_send_pending() {
local retries reauthenticated=0 response_status response_seq credentials_rc=0 refresh_rc=0
upload_pending_load || return 1
upload_ensure_credentials || credentials_rc=$?
case "$credentials_rc" in
0) ;;
2)
upload_pending_clear
return 2
;;
*)
upload_record_failure 'statistics_credentials_unavailable'
return 1
;;
esac
retries=1
while [ "$retries" -le "${ss_download_retry:-3}" ]; do
[ "$ss_should_terminate" -eq 1 ] && return 130
rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE"
if ss_http_post_json \
"$SS_STATISTICS_UPLOAD_URL" \
"$SS_STATISTICS_UPLOAD_PENDING_FILE" \
"$SS_STATS_UPLOAD_RESPONSE_FILE" \
"Bearer $SS_STATISTICS_UPLOAD_TOKEN"; then
if upload_response_valid "$SS_STATS_UPLOAD_RESPONSE_FILE"; then
response_status="$SS_STATS_UPLOAD_RESPONSE_STATUS"
response_seq="$SS_STATS_UPLOAD_RESPONSE_SEQ"
upload_record_success "$response_status" "$response_seq"
log_info "Uploaded SafeShield statistics (${upload_pending_mode}, seq=${upload_pending_seq}, status=${response_status})"
upload_pending_clear
rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE"
return 0
fi
upload_record_failure 'statistics_upload_invalid_response' '200'
log_warn 'Hub statistics upload returned an invalid success response; preserving pending payload for retry'
rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE"
return 1
fi
case "${SS_HTTP_STATUS:-}" in
401)
if [ "$reauthenticated" -eq 0 ]; then
refresh_rc=0
reauthenticated=1
ss_statistics_clear_upload_credentials
ss_statistics_refresh_upload_credentials || refresh_rc=$?
if [ "$refresh_rc" -eq 0 ] && ss_statistics_load_upload_credentials; then
continue
fi
if [ "$refresh_rc" -eq 2 ]; then
log_info 'Cloud statistics upload is no longer enabled for the current license'
upload_pending_clear
return 2
fi
fi
upload_record_failure 'statistics_upload_auth_failed' '401'
log_warn 'Hub rejected statistics upload credentials'
return 1
;;
400 | 409 | 413)
upload_record_failure "statistics_upload_http_${SS_HTTP_STATUS}" "$SS_HTTP_STATUS"
log_error "Hub rejected statistics payload (HTTP ${SS_HTTP_STATUS}); discarding this snapshot and scheduling full reconciliation"
upload_pending_clear
rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE"
return 2
;;
esac
upload_record_failure 'statistics_upload_failed' "${SS_HTTP_STATUS:-0}"
retries=$((retries + 1))
if [ "$retries" -le "${ss_download_retry:-3}" ]; then
upload_sleep "$SS_STATS_UPLOAD_RETRY_DELAY_S" || return 130
fi
done
log_warn "Statistics upload failed after ${ss_download_retry:-3} attempt(s); pending payload will be retried"
return 1
}
main() {
local create_rc=0 wait_s entitlement_rc
ss_load_config || return 1
[ "${ss_enabled}" = '1' ] || return 0
[ "${ss_statistics_enabled}" = '1' ] || return 0
mkdir -p "$SS_STATISTICS_DIR" || return 1
is_valid_integer "$SS_STATS_UPLOAD_INTERVAL_S" || SS_STATS_UPLOAD_INTERVAL_S=1800
[ "$SS_STATS_UPLOAD_INTERVAL_S" -ge 60 ] 2>/dev/null || SS_STATS_UPLOAD_INTERVAL_S=1800
is_valid_integer "$SS_STATS_UPLOAD_IDLE_WAIT_S" || SS_STATS_UPLOAD_IDLE_WAIT_S=15
[ "$SS_STATS_UPLOAD_IDLE_WAIT_S" -ge 1 ] 2>/dev/null || SS_STATS_UPLOAD_IDLE_WAIT_S=15
is_valid_integer "$SS_STATS_UPLOAD_RETRY_DELAY_S" || SS_STATS_UPLOAD_RETRY_DELAY_S=2
is_valid_integer "$SS_STATS_UPLOAD_FULL_EVERY" || SS_STATS_UPLOAD_FULL_EVERY=23
[ "$SS_STATS_UPLOAD_FULL_EVERY" -ge 1 ] 2>/dev/null || SS_STATS_UPLOAD_FULL_EVERY=23
is_valid_integer "$SS_STATS_UPLOAD_DENIED_RECHECK_S" || SS_STATS_UPLOAD_DENIED_RECHECK_S=43200
[ "$SS_STATS_UPLOAD_DENIED_RECHECK_S" -ge 60 ] 2>/dev/null || SS_STATS_UPLOAD_DENIED_RECHECK_S=43200
upload_state_load
while [ "$ss_should_terminate" -eq 0 ]; do
ss_load_config || {
upload_sleep "$SS_STATS_UPLOAD_IDLE_WAIT_S" || break
continue
}
if [ "${ss_enabled}" != '1' ] || [ "${ss_statistics_enabled}" != '1' ]; then
break
fi
wait_s="$SS_STATS_UPLOAD_INTERVAL_S"
if [ -z "${ss_license_key:-}" ]; then
ss_statistics_upload_denied || ss_statistics_disable_cloud_upload >/dev/null 2>&1 || true
fi
if ss_statistics_upload_denied; then
upload_pending_clear
if [ -n "${ss_license_key:-}" ] && ss_statistics_upload_denied_recheck_due "$SS_STATS_UPLOAD_DENIED_RECHECK_S"; then
log_info 'Rechecking Hub statistics upload entitlement'
# Stamp this attempt before network I/O so a failed resolve cannot
# turn into a routine upload retry loop while entitlement is denied.
ss_statistics_set_upload_entitlement denied >/dev/null 2>&1 || true
entitlement_rc=0
ss_statistics_refresh_upload_credentials || entitlement_rc=$?
case "$entitlement_rc" in
0) ;;
2)
upload_sleep "$wait_s" || break
continue
;;
*)
log_warn 'Hub statistics entitlement recheck failed; keeping cloud upload disabled'
upload_sleep "$wait_s" || break
continue
;;
esac
fi
if ss_statistics_upload_denied; then
upload_sleep "$wait_s" || break
continue
fi
fi
if upload_pending_load; then
upload_send_pending || true
else
create_rc=0
upload_pending_create || create_rc=$?
case "$create_rc" in
0)
upload_send_pending || true
;;
1)
wait_s="$SS_STATS_UPLOAD_IDLE_WAIT_S"
;;
2)
# The collector has not produced a sequence newer than the last ACK yet.
;;
esac
fi
upload_sleep "$wait_s" || break
done
rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE"
return 0
}
if [ "${SS_STATS_UPLOAD_LIBRARY_ONLY:-0}" != '1' ]; then
trap 'upload_handle_term' TERM INT
trap 'rm -f "$SS_STATS_UPLOAD_RESPONSE_FILE"' EXIT
main "$@"
fi
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/ucode
'use strict';
const ubus = require('ubus');
function clean(value) {
return replace(sprintf('%s', value || ''), /[\t\r\n]/g, '_');
}
function main() {
let conn = ubus.connect();
if (!conn) {
return;
}
let data = conn.call('dhcp', 'ipv6leases', {});
if (type(data) != 'object' || type(data.device) != 'object') {
conn.disconnect();
return;
}
for (let _, device in data.device) {
if (type(device) != 'object' || type(device.leases) != 'array') {
continue;
}
for (let lease in device.leases) {
if (type(lease) != 'object') {
continue;
}
let duid = lc(clean(lease.duid));
let hostname = clean(lease.hostname);
let addresses = lease['ipv6-addr'];
if (!duid || type(addresses) != 'array') {
continue;
}
for (let address in addresses) {
if (type(address) != 'object') {
continue;
}
let ip = clean(address.address);
if (ip) {
printf('%s\t%s\t%s\n', ip, duid, hostname);
}
}
}
}
conn.disconnect();
}
main();
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/ucode
'use strict';
let ubus = require('ubus').connect();
function token(value) {
let text = sprintf('%s', value == null ? '' : value);
return replace(text, /[\t\r\n]/g, '');
}
function uint_or_null(value) {
if (value == null) {
return null;
}
let number = int(value);
return (type(number) == 'int' && number >= 0) ? number : null;
}
function optional_uint(value) {
let number = uint_or_null(value);
return number == null ? 0 : number;
}
if (!ubus) {
exit(1);
}
let data = ubus.call('dnsmasq', 'safeshield_stats', {});
if (type(data) != 'object' || uint_or_null(data.schema) != 1) {
exit(1);
}
let instance_id = token(data.instance_id);
if (!instance_id || type(data.totals) != 'object' || type(data.clients) != 'array') {
exit(1);
}
let total_queries = uint_or_null(data.totals.queries);
let total_blocked = uint_or_null(data.totals.blocked);
if (total_queries == null || total_blocked == null) {
exit(1);
}
let transport_scope = token(data.transport_scope || 'unknown');
let client_capacity = optional_uint(data.client_capacity);
let tracked_clients = optional_uint(data.tracked_clients);
let untracked_queries = optional_uint(data.untracked_queries);
let untracked_blocked = optional_uint(data.untracked_blocked);
let normalized_clients = [];
for (let client in data.clients) {
if (type(client) != 'object') {
exit(1);
}
let address = token(client.address);
let queries = uint_or_null(client.queries);
let blocked = uint_or_null(client.blocked);
if (!address || queries == null || blocked == null) {
exit(1);
}
push(normalized_clients, {
address: address,
queries: queries,
blocked: blocked
});
}
printf('snapshot\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\n',
instance_id,
transport_scope,
client_capacity,
tracked_clients,
untracked_queries,
untracked_blocked,
total_queries,
total_blocked);
for (let client in normalized_clients) {
printf('client\t%s\t%d\t%d\n',
client.address,
client.queries,
client.blocked);
}
print('commit\n');
exit(0);
+283
View File
@@ -0,0 +1,283 @@
#!/bin/sh
# shellcheck shell=ash
# Variables are populated by sourced SafeShield helpers and ss_load_config().
# shellcheck disable=SC2154
LC_ALL=C
export LC_ALL
umask 077
SS_STATSD_FUNCTIONS_LIB="${SS_STATSD_FUNCTIONS_LIB:-/lib/functions.sh}"
SS_STATSD_CORE_LIB="${SS_STATSD_CORE_LIB:-/usr/lib/safeshield/core.sh}"
SS_STATSD_AWK_DIR="${SS_STATSD_AWK_DIR:-/usr/lib/safeshield/statistics}"
SS_STATSD_POLL_COMMAND="${SS_STATSD_POLL_COMMAND:-/usr/libexec/safeshield-stats-poll}"
SS_STATSD_IDENTITY_COMMAND="${SS_STATSD_IDENTITY_COMMAND:-/usr/libexec/safeshield-stats-identities}"
SS_STATSD_PERSISTENT_STATE_FILE="${SS_STATSD_PERSISTENT_STATE_FILE:-/etc/safeshield/statistics-state.tsv}"
SS_STATSD_PERSISTENT_JOURNAL_FILE="${SS_STATSD_PERSISTENT_JOURNAL_FILE:-/etc/safeshield/statistics-journal.tsv}"
SS_STATSD_SEQUENCE_FILE="${SS_STATSD_SEQUENCE_FILE:-/etc/safeshield/statistics-sequence.tsv}"
SS_STATSD_SEQUENCE_RESERVATION_SIZE="${SS_STATSD_SEQUENCE_RESERVATION_SIZE:-4096}"
SS_STATSD_UPLOAD_WINDOW_HOURS="${SS_STATSD_UPLOAD_WINDOW_HOURS:-2}"
SS_STATSD_PERSISTENT_INTERVAL_S="${SS_STATSD_PERSISTENT_INTERVAL_S:-3600}"
SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S="${SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S:-604800}"
# shellcheck disable=SC1090
. "$SS_STATSD_FUNCTIONS_LIB"
# shellcheck disable=SC1090
. "$SS_STATSD_CORE_LIB"
stats_poller_pid=''
stats_awk_pid=''
stats_fifo=''
stats_lock_dir=''
stats_lock_owned=0
stats_new_generation_id() {
local generation_id="${SS_STATSD_GENERATION_ID:-}"
if [ -n "$generation_id" ]; then
printf '%s\n' "$generation_id"
return 0
fi
if [ -r /proc/sys/kernel/random/uuid ]; then
IFS= read -r generation_id </proc/sys/kernel/random/uuid || generation_id=''
fi
if [ -z "$generation_id" ]; then
generation_id="fallback-$(date +%s 2>/dev/null || printf '0')-$$"
fi
printf '%s\n' "$generation_id"
}
stats_process_running() {
local pid="${1:-}"
[ -n "$pid" ] || return 1
kill -0 "$pid" 2>/dev/null
}
stats_cleanup() {
trap - EXIT HUP INT TERM
# Stop the low-frequency UBus poller first. awk then receives EOF and runs
# its END block, which performs the final snapshot and persistence flush.
if stats_process_running "$stats_poller_pid"; then
kill "$stats_poller_pid" 2>/dev/null || true
fi
[ -n "$stats_poller_pid" ] && wait "$stats_poller_pid" 2>/dev/null || true
stats_poller_pid=''
[ -n "$stats_awk_pid" ] && wait "$stats_awk_pid" 2>/dev/null || true
stats_awk_pid=''
[ -n "$stats_fifo" ] && rm -f "$stats_fifo"
if [ "$stats_lock_owned" -eq 1 ] && [ -n "$stats_lock_dir" ]; then
rm -rf "$stats_lock_dir"
fi
}
stats_terminate() {
local rc="${1:-0}"
stats_cleanup
exit "$rc"
}
stats_acquire_lock() {
local owner_pid=''
stats_lock_dir="${SS_STATISTICS_DIR}/collector.lock"
if mkdir "$stats_lock_dir" 2>/dev/null; then
stats_lock_owned=1
printf '%s\n' "$$" >"${stats_lock_dir}/pid"
return 0
fi
if [ -r "${stats_lock_dir}/pid" ]; then
owner_pid="$(cat "${stats_lock_dir}/pid" 2>/dev/null || true)"
fi
if stats_process_running "$owner_pid"; then
log_warn "Statistics collector is already running (pid=${owner_pid})"
return 1
fi
log_warn 'Removing stale statistics collector lock'
rm -rf "$stats_lock_dir" || return 1
mkdir "$stats_lock_dir" || return 1
stats_lock_owned=1
printf '%s\n' "$$" >"${stats_lock_dir}/pid"
return 0
}
stats_poll_loop() {
local snapshot_interval="$1"
local poll_command="$2"
local sleep_pid=''
trap 'if [ -n "$sleep_pid" ]; then kill "$sleep_pid" 2>/dev/null || true; fi; exit 0' HUP INT TERM
while :; do
if ! "$poll_command"; then
printf 'error\tdnsmasq_ubus_poll_failed\n'
fi
sleep "$snapshot_interval" &
sleep_pid=$!
wait "$sleep_pid" 2>/dev/null || true
sleep_pid=''
done
}
stats_run_collector() {
local snapshot_interval="$1"
local retention_hours="$2"
local lease_file="$3"
local persistent_state_file="$4"
local persistent_journal_file="$5"
local sequence_file="$6"
local generation_id ipv6_neigh_command='' ipv6_identity_command='' ipv6_identity_file="${SS_STATSD_IPV6_IDENTITY_FILE:-}" force_rebaseline=0 rc=0
if [ -f "${SS_STATISTICS_REBASELINE_FILE}" ]; then
force_rebaseline=1
fi
# This is only a candidate for a brand-new dataset. The collector modules keep
# the generation restored from tmpfs or persistent state when one already exists.
generation_id="$(stats_new_generation_id)"
if command_exists ip; then
ipv6_neigh_command='ip -6 neigh show 2>/dev/null'
fi
if [ -z "$ipv6_identity_file" ] && [ -x "$SS_STATSD_IDENTITY_COMMAND" ]; then
ipv6_identity_command="$SS_STATSD_IDENTITY_COMMAND 2>/dev/null"
fi
stats_fifo="${SS_STATISTICS_DIR}/events.$$"
rm -f "$stats_fifo"
mkfifo "$stats_fifo" || {
log_error "Failed to create statistics FIFO: ${stats_fifo}"
return 1
}
stats_poll_loop "$snapshot_interval" "$SS_STATSD_POLL_COMMAND" >"$stats_fifo" &
stats_poller_pid=$!
awk \
-v state_file="${SS_STATISTICS_STATE_FILE}" \
-v json_file="${SS_STATISTICS_JSON_FILE}" \
-v upload_json_file="${SS_STATISTICS_UPLOAD_JSON_FILE}" \
-v upload_window_hours="${SS_STATSD_UPLOAD_WINDOW_HOURS}" \
-v persistent_state_file="${persistent_state_file}" \
-v persistent_journal_file="${persistent_journal_file}" \
-v sequence_file="${sequence_file}" \
-v snapshot_seq_reservation_size="${SS_STATSD_SEQUENCE_RESERVATION_SIZE}" \
-v persistent_interval="${SS_STATSD_PERSISTENT_INTERVAL_S}" \
-v persistent_compact_interval="${SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S}" \
-v snapshot_interval="${snapshot_interval}" \
-v retention_hours="${retention_hours}" \
-v lease_file="${lease_file}" \
-v ipv6_neigh_command="${ipv6_neigh_command}" \
-v ipv6_identity_file="${ipv6_identity_file}" \
-v ipv6_identity_command="${ipv6_identity_command}" \
-v lease_refresh_interval=60 \
-v identity_cache_ttl=60 \
-v max_devices=128 \
-v generation_seed="${generation_id}" \
-v force_rebaseline="${force_rebaseline}" \
-v rebaseline_file="${SS_STATISTICS_REBASELINE_FILE}" \
-f "$SS_STATSD_AWK_DIR/00-common.awk" \
-f "$SS_STATSD_AWK_DIR/10-recovery.awk" \
-f "$SS_STATSD_AWK_DIR/20-identity.awk" \
-f "$SS_STATSD_AWK_DIR/30-aggregate.awk" \
-f "$SS_STATSD_AWK_DIR/40-persistence.awk" \
-f "$SS_STATSD_AWK_DIR/50-output.awk" \
-f "$SS_STATSD_AWK_DIR/90-main.awk" \
<"$stats_fifo" &
stats_awk_pid=$!
wait "$stats_awk_pid" || rc=$?
stats_awk_pid=''
if stats_process_running "$stats_poller_pid"; then
kill "$stats_poller_pid" 2>/dev/null || true
fi
wait "$stats_poller_pid" 2>/dev/null || true
stats_poller_pid=''
return "$rc"
}
main() {
local snapshot_interval configured_snapshot_interval retention_hours lease_file configured_lease_file
local persistent_state_file persistent_journal_file sequence_file persistence_mode rc=0
ss_load_config || return 1
[ "${ss_enabled}" = "1" ] || return 0
[ "${ss_statistics_enabled}" = "1" ] || return 0
if ! ss_dnsmasq_safeshield_block_supported; then
log_warn 'SafeShield statistics collector is disabled because dnsmasq does not provide the SafeShield extension'
return 0
fi
[ -x "$SS_STATSD_POLL_COMMAND" ] || {
log_error "SafeShield statistics poll command is unavailable: ${SS_STATSD_POLL_COMMAND}"
return 1
}
command_exists mkfifo || {
log_error 'mkfifo is required for SafeShield statistics'
return 1
}
persistent_state_file="${SS_STATSD_PERSISTENT_STATE_FILE}"
persistent_journal_file="${SS_STATSD_PERSISTENT_JOURNAL_FILE}"
sequence_file="${SS_STATSD_SEQUENCE_FILE}"
persistence_mode='journal'
if ! ss_statistics_persistence_enabled; then
persistent_state_file=''
persistent_journal_file=''
sequence_file=''
persistence_mode='volatile'
fi
mkdir -p "${SS_STATISTICS_DIR}" || return 1
if [ -n "$persistent_state_file" ]; then
mkdir -p "${persistent_state_file%/*}" || return 1
fi
if [ -n "$persistent_journal_file" ]; then
mkdir -p "${persistent_journal_file%/*}" || return 1
fi
if [ -n "$sequence_file" ]; then
mkdir -p "${sequence_file%/*}" || return 1
fi
stats_acquire_lock || return 0
trap 'stats_cleanup' EXIT
trap 'stats_terminate 129' HUP
trap 'stats_terminate 130' INT
trap 'stats_terminate 143' TERM
configured_snapshot_interval="${ss_statistics_snapshot_interval_s}"
snapshot_interval="$(ss_statistics_effective_snapshot_interval "$configured_snapshot_interval")"
retention_hours="${ss_statistics_retention_hours}"
lease_file="${SS_STATSD_DHCP_LEASE_FILE:-/tmp/dhcp.leases}"
if [ -z "${SS_STATSD_DHCP_LEASE_FILE:-}" ] && command_exists uci; then
configured_lease_file="$(uci -q get dhcp.@dnsmasq[0].leasefile 2>/dev/null || true)"
[ -n "$configured_lease_file" ] && lease_file="$configured_lease_file"
fi
log_info "Starting statistics collector (source=dnsmasq-ubus snapshot=${snapshot_interval}s configured_snapshot=${configured_snapshot_interval}s retention=${retention_hours}h persistence=${persistence_mode} journal=${SS_STATSD_PERSISTENT_INTERVAL_S}s compact=${SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S}s)"
stats_run_collector \
"$snapshot_interval" \
"$retention_hours" \
"$lease_file" \
"$persistent_state_file" \
"$persistent_journal_file" \
"$sequence_file" || rc=$?
return "$rc"
}
main "$@"
@@ -0,0 +1,28 @@
{
"safeshield": {
"description": "Access SafeShield service API",
"read": {
"ubus": {
"safeshield": [
"status",
"config",
"statistics",
"rules_list"
]
}
},
"write": {
"ubus": {
"safeshield": [
"config_update",
"set_enabled",
"refresh",
"rule_add",
"rule_delete",
"license_get",
"license_update"
]
}
}
}
}
@@ -0,0 +1,82 @@
'use strict';
// Keep private SafeShield RPC modules next to this rpcd entrypoint.
// Prepend the private module directory to the ucode require() search path.
unshift(REQUIRE_SEARCH_PATH, '/usr/share/rpcd/ucode/safeshield/*.uc');
let status = require('status');
let config = require('config');
let refresh = require('refresh');
let rules = require('rules');
let license = require('license');
let statistics = require('statistics');
return {
safeshield: {
status: {
call: status.build
},
config: {
call: config.build
},
statistics: {
call: statistics.build
},
config_update: {
args: {
values: {}
},
call: config.update
},
set_enabled: {
args: {
enabled: true
},
call: config.set_enabled
},
refresh: {
call: refresh.call
},
rules_list: {
args: {
action: ''
},
call: rules.list
},
rule_add: {
args: {
action: '',
domain: '',
refresh: true
},
call: rules.add
},
rule_delete: {
args: {
action: '',
domain: '',
refresh: true
},
call: rules.delete
},
license_get: {
call: license.get
},
license_update: {
args: {
license_key: ''
},
call: license.update
}
}
};
@@ -0,0 +1,276 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let uci = core.uci;
let PKG_NAME = core.PKG_NAME;
let CONFIG_SCHEMA_NAME = core.CONFIG_SCHEMA_NAME;
let CONFIG_SCHEMA_VERSION = core.CONFIG_SCHEMA_VERSION;
let CONFIG_SPEC = core.CONFIG_SPEC;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let to_bool = core.to_bool;
let to_int = core.to_int;
let mask_secret = core.mask_secret;
let api_error = core.api_error;
let uci_commit_option = core.uci_commit_option;
let bool_uci = core.bool_uci;
let run_service_action = runtime.run_service_action;
let start_refresh_async = runtime.start_refresh_async;
function config_public_value(name, spec) {
let raw = cfg(name, null);
if (spec.kind == 'bool') {
return to_bool(raw, spec.def);
}
return to_int(raw, spec.def);
}
function build_config() {
reload_uci();
let values = {};
for (let name, spec in CONFIG_SPEC) {
values[name] = config_public_value(name, spec);
}
values.enabled = to_bool(cfg('enabled', '0'), false);
return {
schema: {
name: CONFIG_SCHEMA_NAME,
version: CONFIG_SCHEMA_VERSION
},
values: values,
license: {
configured: !!cfg('license_key', ''),
key_masked: mask_secret(cfg('license_key', ''))
},
device: {
vendor: cfg('device_vendor', ''),
model: cfg('device_model', ''),
arch: cfg('device_arch', ''),
memory_mb: to_int(cfg('device_memory_mb', ''), 0)
}
};
}
function validate_config_value(name, value) {
let spec = CONFIG_SPEC[name];
if (!spec) {
if (name == 'enabled') {
return api_error('dedicated_method_required', 'Use set_enabled to change enabled state', name);
}
if (name == 'license_key') {
return api_error('dedicated_method_required', 'Use license_update to change the license key', name);
}
return api_error('unknown_option', 'Unsupported SafeShield configuration option', name);
}
if (spec.kind == 'bool') {
if (type(value) != 'bool') {
return api_error('invalid_type', 'Expected a boolean value', name);
}
return {
ok: true,
uci: bool_uci(value),
value: value
};
}
if (spec.kind == 'int') {
if (type(value) != 'int') {
return api_error('invalid_type', 'Expected an integer value', name);
}
if (value < spec.min || value > spec.max) {
return api_error(
'out_of_range',
sprintf('Value must be between %d and %d', spec.min, spec.max),
name
);
}
return {
ok: true,
uci: sprintf('%d', value),
value: value
};
}
return api_error('invalid_spec', 'Invalid internal configuration specification', name);
}
function config_update_call(request) {
reload_uci();
let values = request.args.values || {};
let changes = {};
let changed_names = [];
if (type(values) != 'object') {
return api_error('invalid_type', 'values must be an object', 'values');
}
for (let name, value in values) {
let checked = validate_config_value(name, value);
if (!checked.ok) {
return checked;
}
let current = cfg(name, null);
if (sprintf('%s', current) != checked.uci) {
changes[name] = checked.uci;
push(changed_names, name);
}
}
if (length(changed_names) == 0) {
return {
ok: true,
changed: [],
restarted: false,
config: build_config()
};
}
for (let name, value in changes) {
if (!uci.set(PKG_NAME, 'config', name, value)) {
uci.revert(PKG_NAME);
return api_error('uci_set_failed', 'Failed to update SafeShield configuration', name);
}
}
if (!uci.commit(PKG_NAME)) {
uci.revert(PKG_NAME);
return api_error('uci_commit_failed', 'Failed to commit SafeShield configuration');
}
reload_uci();
let statistics_only =
length(changed_names) == 1 &&
changed_names[0] == 'statistics_enabled';
if (statistics_only) {
let reconciled = run_service_action('reconcile_statistics', 60000);
if (!reconciled.ok) {
return {
ok: false,
committed: true,
changed: changed_names,
restarted: false,
reconciled: false,
service_rc: reconciled.rc,
error: {
code: 'statistics_reconcile_failed',
message: 'Configuration was committed but SafeShield statistics runtime reconciliation failed'
},
config: build_config()
};
}
return {
ok: true,
changed: changed_names,
restarted: false,
reconciled: true,
refresh: {
requested: false,
accepted: false,
reason: 'not_required'
},
config: build_config()
};
}
let restarted = run_service_action('restart', 60000);
if (!restarted.ok) {
return {
ok: false,
committed: true,
changed: changed_names,
restarted: false,
service_rc: restarted.rc,
error: {
code: 'service_restart_failed',
message: 'Configuration was committed but SafeShield failed to restart'
},
config: build_config()
};
}
let refresh = start_refresh_async();
return {
ok: true,
changed: changed_names,
restarted: true,
refresh: {
requested: true,
accepted: refresh.accepted,
reason: refresh.reason || ''
},
config: build_config()
};
}
function set_enabled_call(request) {
reload_uci();
if (request.args.enabled == null) {
return api_error('missing_argument', 'enabled is required', 'enabled');
}
if (type(request.args.enabled) != 'bool') {
return api_error('invalid_type', 'enabled must be a boolean', 'enabled');
}
let enabled = request.args.enabled;
let current = to_bool(cfg('enabled', '0'), false);
let changed = enabled != current;
if (changed && !uci_commit_option('enabled', bool_uci(enabled))) {
return api_error('uci_commit_failed', 'Failed to update enabled state');
}
let restarted = run_service_action('restart', 60000);
if (!restarted.ok) {
return {
ok: false,
committed: changed,
changed: changed,
accepted: false,
target_enabled: enabled,
reconciled: false,
service_rc: restarted.rc,
error: {
code: 'service_restart_failed',
message: 'Enabled state is configured but SafeShield failed to request runtime lifecycle reconciliation'
}
};
}
return {
ok: true,
changed: changed,
accepted: true,
target_enabled: enabled,
reconciled: false
};
}
return {
build: build_config,
update: config_update_call,
set_enabled: set_enabled_call
};
@@ -0,0 +1,250 @@
'use strict';
let fs = require('fs');
let uci = require('uci').cursor();
const PKG_NAME = 'safeshield';
const STATUS_FILE = `/dev/shm/${PKG_NAME}.status.json`;
const BLOCKLIST_FILE = '/tmp/dnsmasq.d/safeshield.blocklist';
const LOCAL_ALLOWLIST_FILE = '/etc/safeshield/allowlist';
const LOCAL_BLOCKLIST_FILE = '/etc/safeshield/blocklist';
const SCHEMA_NAME = 'safeshield.status';
const SCHEMA_VERSION = 1;
const CONFIG_SCHEMA_NAME = 'safeshield.config';
const CONFIG_SCHEMA_VERSION = 1;
const RULES_SCHEMA_NAME = 'safeshield.rules';
const RULES_SCHEMA_VERSION = 1;
const STATISTICS_SCHEMA_NAME = 'safeshield.statistics';
const STATISTICS_SCHEMA_VERSION = 3;
const STATISTICS_FILE = '/tmp/safeshield/statistics/statistics.json';
const SERVICE_INIT = '/etc/init.d/safeshield';
const RULES_DIR = '/etc/safeshield';
const CONFIG_SPEC = {
verbosity: { kind: 'int', def: 2, min: 0, max: 7 },
apply_local_overrides: { kind: 'bool', def: true },
max_blocklist_file_size_kb: { kind: 'int', def: 30000, min: 1, max: 2147483647 },
min_valid_line_count: { kind: 'int', def: 3000, min: 0, max: 2147483647 },
compress_blocklist: { kind: 'bool', def: false },
initial_dnsmasq_restart: { kind: 'bool', def: false },
dnsmasq_sanity_check: { kind: 'bool', def: true },
statistics_enabled: { kind: 'bool', def: true },
statistics_snapshot_interval_s: { kind: 'int', def: 60, min: 10, max: 3600 },
statistics_retention_hours: { kind: 'int', def: 168, min: 1, max: 168 },
download_timeout: { kind: 'int', def: 10, min: 1, max: 86400 },
download_retry: { kind: 'int', def: 3, min: 1, max: 100 },
pause_timeout: { kind: 'int', def: 20, min: 1, max: 86400 },
boot_start_delay_s: { kind: 'int', def: 30, min: 0, max: 86400 },
refresh_on_boot: { kind: 'bool', def: true },
refresh_interval_s: { kind: 'int', def: 28800, min: 1, max: 2147483647 },
require_wan: { kind: 'bool', def: true },
debug: { kind: 'bool', def: false }
};
function trim(s) {
return replace(s, /^[\r\n\t ]+|[\r\n\t ]+$/g, '');
}
function pkg_version() {
let s = fs.readfile(`/usr/lib/${PKG_NAME}/version`);
if (!s) {
return 'unknown';
}
return trim(s);
}
const PKG_VERSION = pkg_version();
function read_json_file(path, def) {
let s = fs.readfile(path);
if (!s) {
return def;
}
try {
return json(s);
}
catch (e) {
return def;
}
}
function file_exists(path) {
let st = fs.stat(path);
return !!st;
}
function file_size_kb(path) {
let st = fs.stat(path);
if (!st || type(st.size) != 'int') {
return 0;
}
return int((st.size + 1023) / 1024);
}
function to_bool(v, def) {
if (v == null) {
return def;
}
switch (sprintf('%s', v)) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
case '0':
case 'false':
case 'no':
case 'off':
return false;
}
return def;
}
function to_optional_bool(v) {
if (v == null || v == '') {
return null;
}
switch (sprintf('%s', v)) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
case '0':
case 'false':
case 'no':
case 'off':
return false;
}
return null;
}
function to_int(v, def) {
if (v == null || v == '') {
return def;
}
let n = int(v);
return (type(n) == 'int') ? n : def;
}
function reload_uci() {
return !!uci.load(PKG_NAME);
}
function cfg(name, def) {
let v = uci.get(PKG_NAME, 'config', name);
return (v == null) ? def : v;
}
function identity_cfg(name, def) {
let v = uci.get(PKG_NAME, 'identity', name);
return (v == null) ? def : v;
}
function mask_secret(v) {
let s = sprintf('%s', v || '');
let n = length(s);
if (n == 0) {
return '';
}
if (n <= 8) {
return '********';
}
return sprintf('%s...%s', substr(s, 0, 4), substr(s, n - 4));
}
function api_error(code, message, field) {
let error = {
code: code,
message: message
};
if (field) {
error.field = field;
}
return {
ok: false,
error: error
};
}
function uci_commit_option(name, value) {
if (!uci.set(PKG_NAME, 'config', name, sprintf('%s', value))) {
return false;
}
if (!uci.commit(PKG_NAME)) {
uci.revert(PKG_NAME);
return false;
}
reload_uci();
return true;
}
function uci_delete_option(name) {
if (!uci.delete(PKG_NAME, 'config', name)) {
return false;
}
if (!uci.commit(PKG_NAME)) {
uci.revert(PKG_NAME);
return false;
}
reload_uci();
return true;
}
function bool_uci(v) {
return v ? '1' : '0';
}
return {
uci: uci,
PKG_NAME: PKG_NAME,
PKG_VERSION: PKG_VERSION,
STATUS_FILE: STATUS_FILE,
BLOCKLIST_FILE: BLOCKLIST_FILE,
LOCAL_ALLOWLIST_FILE: LOCAL_ALLOWLIST_FILE,
LOCAL_BLOCKLIST_FILE: LOCAL_BLOCKLIST_FILE,
SCHEMA_NAME: SCHEMA_NAME,
SCHEMA_VERSION: SCHEMA_VERSION,
CONFIG_SCHEMA_NAME: CONFIG_SCHEMA_NAME,
CONFIG_SCHEMA_VERSION: CONFIG_SCHEMA_VERSION,
RULES_SCHEMA_NAME: RULES_SCHEMA_NAME,
RULES_SCHEMA_VERSION: RULES_SCHEMA_VERSION,
STATISTICS_SCHEMA_NAME: STATISTICS_SCHEMA_NAME,
STATISTICS_SCHEMA_VERSION: STATISTICS_SCHEMA_VERSION,
STATISTICS_FILE: STATISTICS_FILE,
SERVICE_INIT: SERVICE_INIT,
RULES_DIR: RULES_DIR,
CONFIG_SPEC: CONFIG_SPEC,
trim: trim,
read_json_file: read_json_file,
file_exists: file_exists,
file_size_kb: file_size_kb,
to_bool: to_bool,
to_optional_bool: to_optional_bool,
to_int: to_int,
reload_uci: reload_uci,
cfg: cfg,
identity_cfg: identity_cfg,
mask_secret: mask_secret,
api_error: api_error,
uci_commit_option: uci_commit_option,
uci_delete_option: uci_delete_option,
bool_uci: bool_uci
};
@@ -0,0 +1,90 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let trim = core.trim;
let mask_secret = core.mask_secret;
let api_error = core.api_error;
let uci_commit_option = core.uci_commit_option;
let uci_delete_option = core.uci_delete_option;
let start_refresh_async = runtime.start_refresh_async;
let reset_statistics_upload_state = runtime.reset_statistics_upload_state;
function license_get_call() {
reload_uci();
let key = cfg('license_key', '');
return {
ok: true,
license: {
configured: !!key,
key: key
}
};
}
function license_update_call(request) {
reload_uci();
if (request.args.license_key == null) {
return api_error('missing_argument', 'license_key is required; use an empty string to clear it', 'license_key');
}
let key = trim(request.args.license_key);
if (length(key) > 512 || match(key, /[\r\n]/)) {
return api_error('invalid_license_key', 'license_key must be at most 512 characters and contain no line breaks', 'license_key');
}
let current = cfg('license_key', '');
if (current == key) {
return {
ok: true,
changed: false,
license: {
configured: !!key,
key_masked: mask_secret(key)
},
refresh: {
requested: false,
accepted: false,
reason: 'unchanged'
}
};
}
let saved = key == ''
? uci_delete_option('license_key')
: uci_commit_option('license_key', key);
if (!saved) {
return api_error('uci_commit_failed', 'Failed to update the SafeShield license key');
}
reset_statistics_upload_state();
let refresh = start_refresh_async();
return {
ok: true,
changed: true,
license: {
configured: !!key,
key_masked: mask_secret(key)
},
refresh: {
requested: true,
accepted: refresh.accepted,
reason: refresh.reason || ''
}
};
}
return {
get: license_get_call,
update: license_update_call
};
@@ -0,0 +1,30 @@
'use strict';
let runtime = require('runtime');
let status = require('status');
let start_refresh_async = runtime.start_refresh_async;
let build_status = status.build;
function refresh_call() {
let r = start_refresh_async();
if (!r.accepted) {
return {
ok: r.reason == 'already_running',
accepted: false,
reason: r.reason,
status: build_status()
};
}
return {
ok: true,
accepted: true,
status: build_status()
};
}
return {
call: refresh_call
};
@@ -0,0 +1,316 @@
'use strict';
let fs = require('fs');
let core = require('core');
let runtime = require('runtime');
let LOCAL_ALLOWLIST_FILE = core.LOCAL_ALLOWLIST_FILE;
let LOCAL_BLOCKLIST_FILE = core.LOCAL_BLOCKLIST_FILE;
let RULES_SCHEMA_NAME = core.RULES_SCHEMA_NAME;
let RULES_SCHEMA_VERSION = core.RULES_SCHEMA_VERSION;
let RULES_DIR = core.RULES_DIR;
let trim = core.trim;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let to_bool = core.to_bool;
let api_error = core.api_error;
let start_local_apply_async = runtime.start_local_apply_async;
function normalize_rule_domain(value) {
if (type(value) != 'string') {
return '';
}
let domain = lc(trim(value));
if (!domain || substr(domain, 0, 1) == '#' || substr(domain, 0, 1) == '!') {
return '';
}
domain = replace(domain, /^0\.0\.0\.0[ \t]+/, '');
domain = replace(domain, /^127\.0\.0\.1[ \t]+/, '');
domain = replace(domain, /^local=\//, '');
domain = replace(domain, /^address=\//, '');
domain = replace(domain, /\/0\.0\.0\.0$/, '');
domain = replace(domain, /\/::$/, '');
domain = replace(domain, /\/$/, '');
domain = replace(domain, /^\//, '');
if (!match(domain, /^[a-z0-9._-]+$/) || length(domain) > 253 || index(domain, '..') >= 0) {
return '';
}
for (let label in split(domain, '.')) {
if (!label || length(label) > 63 || substr(label, 0, 1) == '-' || substr(label, -1) == '-') {
return '';
}
}
return domain;
}
function rule_path(action) {
if (action == 'allow') {
return LOCAL_ALLOWLIST_FILE;
}
if (action == 'block') {
return LOCAL_BLOCKLIST_FILE;
}
return null;
}
function read_rule_entries(path) {
let content = fs.readfile(path) || '';
let entries = [];
let seen = {};
for (let line in split(content, /\n/)) {
let domain = normalize_rule_domain(line);
if (!domain || seen[domain]) {
continue;
}
seen[domain] = true;
push(entries, domain);
}
sort(entries);
return entries;
}
function write_rule_file_atomic(path, content) {
if (!fs.stat(RULES_DIR) && !fs.mkdir(RULES_DIR)) {
return false;
}
let tmp = sprintf('%s.tmp.%d', path, time());
if (fs.writefile(tmp, content) == null) {
fs.unlink(tmp);
return false;
}
if (!fs.chmod(tmp, 0o644)) {
fs.unlink(tmp);
return false;
}
if (!fs.rename(tmp, path)) {
fs.unlink(tmp);
return false;
}
return true;
}
function rule_file_contains(path, domain) {
for (let existing in read_rule_entries(path)) {
if (existing == domain) {
return true;
}
}
return false;
}
function add_rule_to_file(path, domain) {
let content = fs.readfile(path) || '';
if (rule_file_contains(path, domain)) {
return {
ok: true,
changed: false
};
}
if (content && substr(content, -1) != '\n') {
content += '\n';
}
content += domain + '\n';
return {
ok: write_rule_file_atomic(path, content),
changed: true
};
}
function delete_rule_from_file(path, domain) {
let content = fs.readfile(path) || '';
let lines = split(content, /\n/);
let kept = [];
let changed = false;
for (let line in lines) {
if (normalize_rule_domain(line) == domain) {
changed = true;
continue;
}
push(kept, line);
}
if (!changed) {
return {
ok: true,
changed: false
};
}
let next = join('\n', kept);
return {
ok: write_rule_file_atomic(path, next),
changed: true
};
}
function build_rules(action) {
let allow = read_rule_entries(LOCAL_ALLOWLIST_FILE);
let block = read_rule_entries(LOCAL_BLOCKLIST_FILE);
if (action == 'allow') {
block = [];
}
else if (action == 'block') {
allow = [];
}
return {
schema: {
name: RULES_SCHEMA_NAME,
version: RULES_SCHEMA_VERSION
},
allow: allow,
block: block,
counts: {
allow: length(allow),
block: length(block)
}
};
}
function validate_rule_request(args) {
let action = args.action || '';
let domain = normalize_rule_domain(args.domain);
if (!rule_path(action)) {
return api_error('invalid_action', 'action must be either allow or block', 'action');
}
if (!domain) {
return api_error('invalid_domain', 'domain must be a plain valid domain name', 'domain');
}
return {
ok: true,
action: action,
domain: domain,
path: rule_path(action)
};
}
function apply_rule_refresh(args) {
reload_uci();
let should_refresh = (args.refresh == null) ? true : args.refresh;
if (!should_refresh) {
return {
requested: false,
accepted: false,
reason: 'not_requested'
};
}
if (!to_bool(cfg('apply_local_overrides', '1'), true)) {
return {
requested: false,
accepted: false,
reason: 'local_overrides_disabled'
};
}
let r = start_local_apply_async();
return {
requested: true,
accepted: r.accepted,
reason: r.reason || ''
};
}
function rules_list_call(request) {
let action = request.args.action || '';
if (action && !rule_path(action)) {
return api_error('invalid_action', 'action must be allow, block or omitted', 'action');
}
let result = build_rules(action);
result.ok = true;
return result;
}
function rule_add_call(request) {
let checked = validate_rule_request(request.args);
if (!checked.ok) {
return checked;
}
let result = add_rule_to_file(checked.path, checked.domain);
if (!result.ok) {
return api_error('rule_write_failed', 'Failed to update the local rule file');
}
let refresh = result.changed
? apply_rule_refresh(request.args)
: { requested: false, accepted: false, reason: 'unchanged' };
return {
ok: true,
action: checked.action,
domain: checked.domain,
added: result.changed,
refresh: refresh,
rules: build_rules(checked.action)
};
}
function rule_delete_call(request) {
let checked = validate_rule_request(request.args);
if (!checked.ok) {
return checked;
}
let result = delete_rule_from_file(checked.path, checked.domain);
if (!result.ok) {
return api_error('rule_write_failed', 'Failed to update the local rule file');
}
let refresh = result.changed
? apply_rule_refresh(request.args)
: { requested: false, accepted: false, reason: 'unchanged' };
return {
ok: true,
action: checked.action,
domain: checked.domain,
deleted: result.changed,
refresh: refresh,
rules: build_rules(checked.action)
};
}
return {
list: rules_list_call,
add: rule_add_call,
delete: rule_delete_call
};
@@ -0,0 +1,169 @@
'use strict';
let core = require('core');
let fs = require('fs');
let ubus = require('ubus').connect();
let PKG_NAME = core.PKG_NAME;
let STATUS_FILE = core.STATUS_FILE;
let SERVICE_INIT = core.SERVICE_INIT;
let read_json_file = core.read_json_file;
let to_bool = core.to_bool;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
function service_running(name) {
let r = ubus.call('service', 'list', { name: name });
if (!r || !r[name] || !r[name].instances) {
return false;
}
for (let inst_name, inst in r[name].instances) {
if (inst.running) {
return true;
}
}
return false;
}
function service_instance_running(name, instance_name) {
let r = ubus.call('service', 'list', { name: name });
if (!r || !r[name] || !r[name].instances || !r[name].instances[instance_name]) {
return false;
}
return !!r[name].instances[instance_name].running;
}
function dnsmasq_running() {
let r = ubus.call('service', 'list', { name: 'dnsmasq' });
if (!r || !r.dnsmasq || !r.dnsmasq.instances) {
return false;
}
for (let inst_name, inst in r.dnsmasq.instances) {
if (inst.running) {
return true;
}
}
return false;
}
function run_service_action(action, timeout_ms) {
let rc = system([ SERVICE_INIT, action ], timeout_ms || 60000);
return {
ok: rc == 0,
rc: rc
};
}
function refresh_running() {
let state = read_json_file(STATUS_FILE, {});
let data = state.data || {};
return data.status == 'running';
}
function reset_statistics_upload_state() {
fs.unlink('/tmp/safeshield/statistics/upload.credentials');
fs.unlink('/tmp/safeshield/statistics/upload.entitlement');
fs.unlink('/tmp/safeshield/statistics/upload.pending.json');
fs.unlink('/tmp/safeshield/statistics/upload.pending.meta');
fs.unlink('/tmp/safeshield/statistics/upload.state');
}
function start_refresh_async() {
reload_uci();
if (!to_bool(cfg('enabled', '0'), false)) {
return {
accepted: false,
reason: 'disabled'
};
}
if (!service_running(PKG_NAME)) {
return {
accepted: false,
reason: 'service_stopped'
};
}
if (refresh_running()) {
return {
accepted: false,
reason: 'already_running'
};
}
let rc = system([
'/bin/sh',
'-c',
'/etc/init.d/safeshield refresh_once </dev/null >/dev/null 2>&1 &'
], 5000);
return {
accepted: rc == 0,
reason: (rc == 0) ? '' : 'spawn_failed',
rc: rc
};
}
function start_local_apply_async() {
reload_uci();
if (!to_bool(cfg('enabled', '0'), false)) {
return {
accepted: false,
reason: 'disabled'
};
}
if (!to_bool(cfg('apply_local_overrides', '1'), true)) {
return {
accepted: false,
reason: 'local_overrides_disabled'
};
}
if (!service_running(PKG_NAME)) {
return {
accepted: false,
reason: 'service_stopped'
};
}
// The shell worker shares the refresh lock with full artifact refreshes.
// It waits for an in-flight refresh and then merges the newest local rule
// files against the retained api.block.txt cache. Duplicate workers are
// harmless because the engine fingerprints the normalized local state.
let rc = system([
'/bin/sh',
'-c',
'/etc/init.d/safeshield apply_local_rules </dev/null >/dev/null 2>&1 &'
], 5000);
return {
accepted: rc == 0,
reason: (rc == 0) ? '' : 'spawn_failed',
rc: rc
};
}
return {
service_running: service_running,
service_instance_running: service_instance_running,
dnsmasq_running: dnsmasq_running,
run_service_action: run_service_action,
refresh_running: refresh_running,
reset_statistics_upload_state: reset_statistics_upload_state,
start_refresh_async: start_refresh_async,
start_local_apply_async: start_local_apply_async
};
reload_service() {
restart
}
@@ -0,0 +1,142 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let PKG_NAME = core.PKG_NAME;
let STATISTICS_SCHEMA_NAME = core.STATISTICS_SCHEMA_NAME;
let STATISTICS_SCHEMA_VERSION = core.STATISTICS_SCHEMA_VERSION;
let STATISTICS_FILE = core.STATISTICS_FILE;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let read_json_file = core.read_json_file;
let to_bool = core.to_bool;
let to_int = core.to_int;
let service_instance_running = runtime.service_instance_running;
function sanitize_hourly(items) {
let result = [];
if (type(items) != 'array') {
return result;
}
for (let item in items) {
if (type(item) != 'object') {
continue;
}
push(result, {
bucket_start: to_int(item.bucket_start, 0),
queries: to_int(item.queries, 0),
blocked: to_int(item.blocked, 0)
});
}
return result;
}
function sanitize_devices(items) {
let result = [];
if (type(items) != 'array') {
return result;
}
for (let item in items) {
if (type(item) != 'object') {
continue;
}
let id = sprintf('%s', item.id || '');
if (!id) {
continue;
}
push(result, {
id: id,
mac: sprintf('%s', item.mac || ''),
ip: sprintf('%s', item.ip || ''),
hostname: sprintf('%s', item.hostname || ''),
identified: to_bool(item.identified, false),
queries: to_int(item.queries, 0),
blocked: to_int(item.blocked, 0),
hourly: sanitize_hourly(item.hourly)
});
}
return result;
}
function sanitize_source(item) {
if (type(item) != 'object') {
item = {};
}
return {
backend: sprintf('%s', item.backend || 'dnsmasq_ubus'),
available: to_bool(item.available, false),
instance_id: sprintf('%s', item.instance_id || ''),
transport_scope: sprintf('%s', item.transport_scope || ''),
client_capacity: to_int(item.client_capacity, 0),
tracked_clients: to_int(item.tracked_clients, 0),
untracked_queries: to_int(item.untracked_queries, 0),
untracked_blocked: to_int(item.untracked_blocked, 0),
poll_error_count: to_int(item.poll_error_count, 0),
last_error_at: to_int(item.last_error_at, 0)
};
}
function build_statistics() {
reload_uci();
let enabled = to_bool(cfg('statistics_enabled', '1'), true);
let retention_hours = to_int(cfg('statistics_retention_hours', '168'), 168);
let snapshot_interval_s = to_int(cfg('statistics_snapshot_interval_s', '60'), 60);
let data = read_json_file(STATISTICS_FILE, {});
let totals = (type(data.totals) == 'object') ? data.totals : {};
let collector_running = service_instance_running(PKG_NAME, 'statistics');
let effective_enabled = enabled && collector_running;
let source = sanitize_source(data.source);
if (!effective_enabled) {
source.available = false;
}
return {
schema: {
name: STATISTICS_SCHEMA_NAME,
version: STATISTICS_SCHEMA_VERSION
},
enabled: enabled,
effective_enabled: effective_enabled,
available: !!data.schema,
collector_running: collector_running,
volatile: to_bool(data.volatile, true),
storage: sprintf('%s', data.storage || 'tmpfs'),
persistent: to_bool(data.persistent, false),
persistence_enabled: to_bool(data.persistence_enabled, false),
persistence_healthy: to_bool(data.persistence_healthy, false),
persistence_mode: sprintf('%s', data.persistence_mode || 'none'),
persistent_error_count: to_int(data.persistent_error_count, 0),
persistent_last_error_at: to_int(data.persistent_last_error_at, 0),
persistent_updated_at: to_int(data.persistent_updated_at, 0),
persistent_checkpoint_interval_s: to_int(data.persistent_checkpoint_interval_s, 3600),
persistent_compacted_at: to_int(data.persistent_compacted_at, 0),
persistent_compact_interval_s: to_int(data.persistent_compact_interval_s, 604800),
snapshot_interval_s: snapshot_interval_s,
effective_snapshot_interval_s: to_int(data.snapshot_interval_s, snapshot_interval_s),
retention_hours: retention_hours,
generation_id: sprintf('%s', data.generation_id || ''),
snapshot_seq: to_int(data.snapshot_seq, 0),
source: source,
started_at: to_int(data.started_at, 0),
session_started_at: to_int(data.session_started_at, 0),
updated_at: to_int(data.updated_at, 0),
totals: {
queries: to_int(totals.queries, 0),
blocked: to_int(totals.blocked, 0)
},
hourly: sanitize_hourly(data.hourly),
device_limit: to_int(data.device_limit, 128),
devices_truncated: to_bool(data.devices_truncated, false),
devices: sanitize_devices(data.devices)
};
}
return {
build: build_statistics
};
@@ -0,0 +1,364 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let PKG_NAME = core.PKG_NAME;
let PKG_VERSION = core.PKG_VERSION;
let STATUS_FILE = core.STATUS_FILE;
let BLOCKLIST_FILE = core.BLOCKLIST_FILE;
let LOCAL_ALLOWLIST_FILE = core.LOCAL_ALLOWLIST_FILE;
let LOCAL_BLOCKLIST_FILE = core.LOCAL_BLOCKLIST_FILE;
let SCHEMA_NAME = core.SCHEMA_NAME;
let SCHEMA_VERSION = core.SCHEMA_VERSION;
let reload_uci = core.reload_uci;
let cfg = core.cfg;
let identity_cfg = core.identity_cfg;
let read_json_file = core.read_json_file;
let file_exists = core.file_exists;
let file_size_kb = core.file_size_kb;
let to_bool = core.to_bool;
let to_optional_bool = core.to_optional_bool;
let to_int = core.to_int;
let mask_secret = core.mask_secret;
let service_running = runtime.service_running;
let dnsmasq_running = runtime.dnsmasq_running;
function build_api_source(data, enabled) {
let api_resolve_ok = to_optional_bool(data.health_api_resolve);
let artifact_download_ok = to_optional_bool(data.health_artifact_download);
let last_result = data.last_result || '';
let last_error_code = data.last_error_code || '';
if (!enabled) {
last_result = 'disabled';
}
else if (api_resolve_ok == true && artifact_download_ok == true) {
last_result = 'ok';
}
else if (api_resolve_ok == false) {
last_result = last_error_code == 'safeshield_upgrade_required' ? last_error_code : 'api_resolve_failed';
}
else if (artifact_download_ok == false) {
last_result = last_error_code == 'safeshield_upgrade_required' ? last_error_code : 'artifact_download_failed';
}
else if (!last_result || last_result == 'idle') {
last_result = 'configured';
}
return {
section: 'hub_api',
name: 'SmartSafeHub API Artifacts',
action: 'block',
enabled: enabled,
last_result: last_result,
line_count: to_int(data.artifact_unique_domains || data.valid_line_count || 0, 0),
size_kb: to_int(data.blocklist_file_size_kb || 0, 0),
artifact_tier: data.artifact_tier || '',
artifact_version: data.artifact_version || '',
source_count: to_int(data.artifact_source_count || (to_bool(data.artifact_download_url_present || '0', false) ? 1 : 0), 0),
block_source_count: to_int(data.artifact_block_source_count || (to_bool(data.artifact_download_url_present || '0', false) ? 1 : 0), 0),
allow_source_count: to_int(data.artifact_allow_source_count || 0, 0)
};
}
function first_error_code(errors) {
if (type(errors) != 'array' || length(errors) == 0) {
return '';
}
let e = errors[0];
if (type(e) == 'object' && e.code != null) {
return sprintf('%s', e.code);
}
return sprintf('%s', e);
}
function build_summary(status, stage, valid_line_count, artifact_tier, artifact_version, errors, warnings) {
let sev = 'info';
let label = 'Idle';
let msg = 'SafeShield is idle';
if (status == 'ready') {
label = 'Ready';
if (artifact_tier || artifact_version) {
msg = sprintf('Hub artifact %s/%s is active, %d rules active', artifact_tier || 'unknown', artifact_version || 'unknown', valid_line_count);
}
else {
msg = sprintf('Hub artifact is active, %d rules active', valid_line_count);
}
}
else if (status == 'running') {
sev = 'notice';
label = 'Running';
msg = stage ? sprintf('Refresh in progress (%s)', stage) : 'Refresh in progress';
}
else if (status == 'error') {
sev = 'error';
label = 'Error';
msg = first_error_code(errors) || 'Refresh failed';
}
else if (status == 'paused') {
sev = 'warning';
label = 'Paused';
msg = 'SafeShield is paused';
}
else if (status == 'disabled') {
sev = 'warning';
label = 'Disabled';
msg = 'SafeShield is disabled';
}
if (type(warnings) == 'array' && length(warnings) > 0 && sev == 'info') {
sev = 'warning';
}
return {
label: label,
message: msg,
severity: sev
};
}
function build_status() {
reload_uci();
let state = read_json_file(STATUS_FILE, {});
let data = state.data || {};
let enabled = to_bool(cfg('enabled', '0'), false);
let refresh_on_boot = to_bool(cfg('refresh_on_boot', '1'), true);
let require_wan = to_bool(cfg('require_wan', '1'), true);
let refresh_interval_s = to_int(cfg('refresh_interval_s', '28800'), 28800);
let boot_start_delay_s = to_int(cfg('boot_start_delay_s', '30'), 30);
let license_key = cfg('license_key', '');
let license_configured = !!license_key;
let apply_local_overrides = to_bool(cfg('apply_local_overrides', '1'), true);
let cfg_physical_fingerprint = identity_cfg('physical_fingerprint', '');
let cfg_fingerprint_version = identity_cfg('fingerprint_version', '1');
let cfg_identity_provider = identity_cfg('identity_provider', '');
let cfg_identity_source = identity_cfg('identity_source', '');
let cfg_identity_strength = identity_cfg('identity_strength', '');
let cfg_identity_profile = identity_cfg('identity_profile', '');
let cfg_device_code = identity_cfg('device_code', '');
let cfg_device_code_source = identity_cfg('device_code_source', 'unknown');
let cfg_installation_id = identity_cfg('installation_id', '');
let cfg_device_vendor = cfg('device_vendor', '');
let cfg_device_model = cfg('device_model', '');
let cfg_device_arch = cfg('device_arch', '');
let cfg_device_memory_mb = cfg('device_memory_mb', '');
let status = data.status || (enabled ? 'idle' : 'disabled');
let stage = data.stage || '';
let valid_line_count = to_int(data.valid_line_count || 0, 0);
let warnings = (type(state.warnings) == 'array') ? state.warnings : [];
let errors = (type(state.errors) == 'array') ? state.errors : [];
let last_attempt = to_int(data.last_attempt || 0, 0);
let last_success = to_int(data.last_success || 0, 0);
let last_failure = to_int(data.last_failure || 0, 0);
let last_local_apply = to_int(data.last_local_apply || 0, 0);
let last_local_apply_failure = to_int(data.last_local_apply_failure || 0, 0);
let next_refresh_at = to_int(data.next_refresh_at || 0, 0);
let last_result = data.last_result || '';
let last_error_code = data.last_error_code || '';
let blocklist_file_size_kb = to_int(data.blocklist_file_size_kb || 0, 0);
let blocklist_installed = to_bool(data.blocklist_installed || '0', false);
let blocklist_verification_ok = to_bool(data.blocklist_verification_ok || '0', false);
let blocklist_test_domain = data.blocklist_test_domain || '';
let blocklist_test_domain_sample_count = to_int(data.blocklist_test_domain_sample_count || 0, 0);
let blocklist_test_domain_success_count = to_int(data.blocklist_test_domain_success_count || 0, 0);
let blocklist_test_domains = data.blocklist_test_domains || '';
let blocklist_backup_available = to_bool(data.blocklist_backup_available || '0', false);
let artifact_tier = data.artifact_tier || '';
let artifact_version = data.artifact_version || '';
let artifact_sha256 = data.artifact_sha256 || '';
let artifact_unique_domains = to_int(data.artifact_unique_domains || 0, 0);
let artifact_rules = to_int(data.artifact_rules || 0, 0);
let artifact_download_url_present = to_bool(data.artifact_download_url_present || '0', false);
let artifact_source_count = to_int(data.artifact_source_count || (artifact_download_url_present ? 1 : 0), 0);
let artifact_block_source_count = to_int(data.artifact_block_source_count || (artifact_download_url_present ? 1 : 0), 0);
let artifact_allow_source_count = to_int(data.artifact_allow_source_count || 0, 0);
let license_plan = data.license_plan || '';
let license_status = data.license_status || '';
let physical_fingerprint = data.physical_fingerprint || cfg_physical_fingerprint || '';
let fingerprint_version = to_int(data.fingerprint_version || cfg_fingerprint_version || 1, 1);
let identity_provider = data.identity_provider || cfg_identity_provider || '';
let identity_source = data.identity_source || cfg_identity_source || '';
let identity_strength = data.identity_strength || cfg_identity_strength || '';
let identity_profile = data.identity_profile || cfg_identity_profile || '';
let device_code = data.device_code || cfg_device_code || '';
let device_code_source = data.device_code_source || cfg_device_code_source || 'unknown';
let installation_id = data.installation_id || cfg_installation_id || '';
let device_profile = data.device_profile || '';
let dnsmasq_version = data.dnsmasq_version || '';
let dnsmasq_min_version = data.dnsmasq_min_version || '2.93';
let health_dnsmasq_binary = to_bool(data.health_dnsmasq_binary || '0', false);
let health_dnsmasq_version = to_bool(data.health_dnsmasq_version || '0', false);
let health_dnsmasq_features = to_bool(data.health_dnsmasq_features || '0', false);
let health_dnsmasq_confdir = to_bool(data.health_dnsmasq_confdir || '0', false);
let health_dnsmasq_initial_restart = to_optional_bool(data.health_dnsmasq_initial_restart);
let health_dnsmasq_final_restart = to_bool(data.health_dnsmasq_final_restart || '0', false);
let health_dns_runtime = to_bool(data.health_dns_runtime || '0', false);
let health_blocklist_verify = to_bool(data.health_blocklist_verify || '0', false);
let health_min_valid_line_count = to_bool(data.health_min_valid_line_count || '0', false);
let health_max_file_size = to_bool(data.health_max_file_size || '0', false);
let health_api_resolve = to_optional_bool(data.health_api_resolve);
let health_artifact_download = to_optional_bool(data.health_artifact_download);
let health_artifact_sha256 = to_optional_bool(data.health_artifact_sha256);
let hub_source = build_api_source(data, enabled);
let installed = blocklist_installed || file_exists(BLOCKLIST_FILE);
let refreshd_running = service_running(PKG_NAME);
let dns_running = dnsmasq_running();
let generated_at = time();
let next_refresh_in_s = 0;
if (next_refresh_at > generated_at) {
next_refresh_in_s = next_refresh_at - generated_at;
}
return {
schema: {
name: SCHEMA_NAME,
version: SCHEMA_VERSION
},
name: PKG_NAME,
version: PKG_VERSION,
enabled: enabled,
active: refreshd_running,
status: status,
stage: stage,
summary: build_summary(status, stage, valid_line_count, artifact_tier, artifact_version, errors, warnings),
runtime: {
refreshd_running: refreshd_running,
dnsmasq_running: dns_running,
dnsmasq_version: dnsmasq_version,
dnsmasq_min_version: dnsmasq_min_version,
dns_runtime_ok: health_dns_runtime || dns_running,
config_loaded: true,
require_wan: require_wan,
refresh_on_boot: refresh_on_boot,
last_result: last_result,
last_error_code: last_error_code
},
license: {
configured: license_configured,
key_masked: mask_secret(license_key),
plan: license_plan,
status: license_status
},
device: {
physical_fingerprint: physical_fingerprint,
fingerprint_version: fingerprint_version,
identity_provider: identity_provider,
identity_source: identity_source,
identity_strength: identity_strength,
identity_profile: identity_profile,
device_code: device_code,
device_code_source: device_code_source,
installation_id: installation_id,
profile: device_profile,
configured: {
physical_fingerprint: cfg_physical_fingerprint,
vendor: cfg_device_vendor,
model: cfg_device_model,
arch: cfg_device_arch,
memory_mb: to_int(cfg_device_memory_mb, 0)
}
},
artifact: {
resolved: !!artifact_tier || !!artifact_version || artifact_download_url_present,
tier: artifact_tier,
version: artifact_version,
sha256: artifact_sha256,
unique_domains: artifact_unique_domains,
rules: artifact_rules,
download_url_present: artifact_download_url_present,
source_count: artifact_source_count,
block_source_count: artifact_block_source_count,
allow_source_count: artifact_allow_source_count
},
local_overrides: {
enabled: apply_local_overrides,
allowlist_path: LOCAL_ALLOWLIST_FILE,
blocklist_path: LOCAL_BLOCKLIST_FILE
},
blocklist: {
installed: installed,
path: BLOCKLIST_FILE,
valid_line_count: valid_line_count,
file_size_kb: blocklist_file_size_kb || file_size_kb(BLOCKLIST_FILE),
verification_ok: blocklist_verification_ok,
test_domain: blocklist_test_domain,
test_domain_sample_count: blocklist_test_domain_sample_count,
test_domain_success_count: blocklist_test_domain_success_count,
test_domains: blocklist_test_domains,
compressed: false,
previous_backup_available: blocklist_backup_available
},
sources: {
mode: 'hub_api',
total: 1,
enabled: hub_source.enabled ? 1 : 0,
items: [ hub_source ]
},
health: {
overall: (status == 'error') ? 'error' : ((type(warnings) == 'array' && length(warnings) > 0) ? 'warning' : 'ok'),
checks: {
api_resolve: health_api_resolve,
artifact_download: health_artifact_download,
artifact_sha256: health_artifact_sha256,
dnsmasq_binary: health_dnsmasq_binary,
dnsmasq_version: health_dnsmasq_version,
dnsmasq_features: health_dnsmasq_features,
dnsmasq_confdir: health_dnsmasq_confdir,
dnsmasq_initial_restart: health_dnsmasq_initial_restart,
dnsmasq_final_restart: health_dnsmasq_final_restart,
dns_runtime: health_dns_runtime,
blocklist_verify: health_blocklist_verify,
min_valid_line_count: health_min_valid_line_count,
max_file_size: health_max_file_size
}
},
warnings: warnings,
errors: errors,
timestamps: {
generated_at: generated_at,
last_attempt: last_attempt,
last_success: last_success,
last_failure: last_failure,
last_local_apply: last_local_apply,
last_local_apply_failure: last_local_apply_failure,
next_refresh_at: next_refresh_at,
refresh_interval_s: refresh_interval_s,
next_refresh_in_s: next_refresh_in_s,
boot_start_delay_s: boot_start_delay_s
}
};
}
return {
build: build_status
};
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
# shellcheck shell=sh
set -eu
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
TMPFILE="$(mktemp)"
cleanup() {
rm -f "$TMPFILE"
}
trap cleanup EXIT HUP INT TERM
cd "$ROOT"
command -v shfmt >/dev/null 2>&1 || {
echo "error: shfmt is required" >&2
exit 127
}
command -v shellcheck >/dev/null 2>&1 || {
echo "error: shellcheck is required" >&2
exit 127
}
collect_file() {
file="$1"
[ -f "$file" ] || return 0
case "$file" in
spec/*_spec.sh)
# ShellSpec DSL is valid shell syntax but is not supported by shfmt.
return 0
;;
*.sh | */init.d/* | */uci-defaults/* | */hotplug.d/*)
printf '%s\n' "$file" >>"$TMPFILE"
return 0
;;
esac
if head -n 1 "$file" 2>/dev/null | grep -Eq '^#!.*(sh|ash|bash)([[:space:]]|$)'; then
printf '%s\n' "$file" >>"$TMPFILE"
fi
}
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git ls-files | while IFS= read -r file; do
collect_file "$file"
done
else
find . -type f -print | sed 's#^\./##' | while IFS= read -r file; do
collect_file "$file"
done
fi
sort -u "$TMPFILE" -o "$TMPFILE"
if [ ! -s "$TMPFILE" ]; then
echo "No shell files found."
exit 0
fi
echo "Checking shell formatting with shfmt..."
xargs -r shfmt -d -ci <"$TMPFILE"
echo "Checking shell scripts with ShellCheck..."
xargs -r shellcheck -x -S warning <"$TMPFILE"
echo "Checking shell syntax..."
while IFS= read -r file; do
sh -n "$file"
done <"$TMPFILE"
echo "Shell lint passed."
+38
View File
@@ -0,0 +1,38 @@
# shellcheck shell=sh
Describe 'SafeShield blocklist behavior'
It 'normalizes, merges, samples, and verifies dnsmasq rules'
When call ss_case_blocklist_format
The status should be success
The output should equal ''
The error should equal ''
End
It 'resolves and downloads multiple Hub artifact sources'
When call ss_case_multi_artifact
The status should be success
The output should equal ''
The error should equal ''
End
It 'retries artifact downloads and enforces size and checksum integrity'
When call ss_case_artifact_integrity
The status should be success
The output should equal ''
The error should equal ''
End
It 'stops retries when the Hub requires a SafeShield upgrade'
When call ss_case_upgrade_required
The status should be success
The output should equal ''
The error should equal ''
End
It 'keeps optimized merge and statistics serialization paths'
When call ss_case_performance_paths
The status should be success
The output should equal ''
The error should equal ''
End
End
+59
View File
@@ -0,0 +1,59 @@
# shellcheck shell=sh
Describe 'SafeShield core shell behavior'
It 'validates utility helpers'
When call ss_case_utils
The status should be success
The output should equal ''
The error should equal ''
End
It 'validates configuration defaults and boundaries'
When call ss_case_config_validation
The status should be success
The output should equal ''
The error should equal ''
End
It 'validates supported dnsmasq versions'
When call ss_case_dnsmasq_version
The status should be success
The output should equal ''
The error should equal ''
End
It 'validates device identity behavior'
When call ss_case_identity
The status should be success
The output should equal ''
The error should equal ''
End
It 'validates Hub resolve payload metadata'
When call ss_case_resolve_payload
The status should be success
The output should equal ''
The error should equal ''
End
It 'validates rpcd and ACL contracts'
When call ss_case_rpcd_contract
The status should be success
The output should equal ''
The error should equal ''
End
It 'validates status state transitions and blocklist restore'
When call ss_case_status_state
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
The output should equal ''
The error should equal ''
End
End
+31
View File
@@ -0,0 +1,31 @@
# shellcheck shell=sh
Describe 'SafeShield lifecycle and refresh state machines'
It 'rolls back full refresh failures while preserving the active blocklist for upgrade-required responses'
When call ss_case_refresh_state_machine
The status should be success
The output should equal ''
The error should equal ''
End
It 'applies cached local rules idempotently and rolls back failed fast applies'
When call ss_case_local_apply_state_machine
The status should be success
The output should equal ''
The error should equal ''
End
It 'serializes full refresh and local apply workers with a termination-aware lock'
When call ss_case_refresh_locking
The status should be success
The output should equal ''
The error should equal ''
End
It 'starts and stops SafeShield safely across disabled, statistics, and migration paths'
When call ss_case_service_lifecycle
The status should be success
The output should equal ''
The error should equal ''
End
End
+59
View File
@@ -0,0 +1,59 @@
# shellcheck shell=sh
Describe 'SafeShield runtime process behavior'
It 'interrupts refreshd long sleeps without one-second polling'
When call ss_case_refreshd_wait
The status should be success
The output should equal ''
The error should equal ''
End
It 'schedules refreshes without retry storms and handles boot, WAN, and disabled states'
When call ss_case_refreshd_scheduler
The status should be success
The output should equal ''
The error should equal ''
End
It 'starts and stops the statistics collector cleanly'
When call ss_case_statistics_collector
The status should be success
The output should equal ''
The error should equal ''
End
It 'uploads statistics idempotently and reconciles after failures'
When call ss_case_statistics_uploader
The status should be success
The output should equal ''
The error should equal ''
End
It 'stores Hub statistics upload credentials only in the runtime cache'
When call ss_case_statistics_upload_credentials
The status should be success
The output should equal ''
The error should equal ''
End
It 'sends the Hub statistics bearer credential as an HTTP authorization header'
When call ss_case_statistics_http_auth
The status should be success
The output should equal ''
The error should equal ''
End
It 'uses authenticated uclient-fetch safely on OpenWrt'
When call ss_case_http_uclient_transport
The status should be success
The output should equal ''
The error should equal ''
End
It 'reconciles statistics runtime without disturbing refreshd'
When call ss_case_statistics_reconcile
The status should be success
The output should equal ''
The error should equal ''
End
End
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# shellcheck shell=sh
set -eu
spec_helper_precheck() {
minimum_version "0.28.1"
}
SS_SPEC_ROOT="${SHELLSPEC_PROJECT_ROOT:-$(pwd)}"
export SS_SPEC_ROOT
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/common.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/core_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/lifecycle_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/blocklist_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/runtime_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/statistics_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/ucode_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/tooling_cases.sh"
+129
View File
@@ -0,0 +1,129 @@
# shellcheck shell=sh
Describe 'SafeShield statistics behavior'
It 'collects global and per-device DNS statistics'
When call ss_case_statistics
The status should be success
The output should equal ''
The error should equal ''
End
It 'calculates deltas from dnsmasq cumulative UBus counters'
When call ss_case_statistics_ubus_source
The status should be success
The output should equal ''
The error should equal ''
End
It 'restores persistent state and handles volatile mode metadata'
When call ss_case_statistics_persistence
The status should be success
The output should equal ''
The error should equal ''
End
It 'replays, compacts, and validates the statistics journal'
When call ss_case_statistics_journal
The status should be success
The output should equal ''
The error should equal ''
End
It 'does not replay journal transactions older than the persistent base'
When call ss_case_statistics_stale_journal
The status should be success
The output should equal ''
The error should equal ''
End
It 'excludes internal loopback DNS health checks from statistics'
When call ss_case_statistics_internal_queries
The status should be success
The output should equal ''
The error should equal ''
End
It 'keeps a generation stable while collector sessions restart'
When call ss_case_statistics_generation
The status should be success
The output should equal ''
The error should equal ''
End
It 'keeps snapshot sequences monotonic across restart and persistent recovery'
When call ss_case_statistics_snapshot_sequence
The status should be success
The output should equal ''
The error should equal ''
End
It 'uses snapshot sequence instead of wall clock to choose recovery state'
When call ss_case_statistics_snapshot_freshness
The status should be success
The output should equal ''
The error should equal ''
End
It 'replays newer journal transactions by snapshot sequence across NTP rollback'
When call ss_case_statistics_journal_sequence_freshness
The status should be success
The output should equal ''
The error should equal ''
End
It 'does not compare snapshot sequence numbers across different generations'
When call ss_case_statistics_generation_sequence_scope
The status should be success
The output should equal ''
The error should equal ''
End
It 'keeps unresolved IPv6 clients as explicit temporary identities'
When call ss_case_statistics_ipv6_identity
The status should be success
The output should equal ''
The error should equal ''
End
It 'merges IPv6 privacy addresses through the kernel neighbor identity'
When call ss_case_statistics_ipv6_neighbor_identity
The status should be success
The output should equal ''
The error should equal ''
End
It 'merges private MAC rotation by DHCP client identifier'
When call ss_case_statistics_dhcp_client_id_identity
The status should be success
The output should equal ''
The error should equal ''
End
It 'merges IPv6 private MAC rotation by DHCPv6 DUID'
When call ss_case_statistics_ipv6_duid_identity
The status should be success
The output should equal ''
The error should equal ''
End
It 'uses conservative hostname fallback for historical MAC identities'
When call ss_case_statistics_hostname_identity
The status should be success
The output should equal ''
The error should equal ''
End
It 'projects a recent upload window with internally consistent totals'
When call ss_case_statistics_upload_projection
The status should be success
The output should equal ''
The error should equal ''
End
It 'loads the statistics collector from focused awk modules'
When call ss_case_statistics_modules
The status should be success
The output should equal ''
The error should equal ''
End
End
+445
View File
@@ -0,0 +1,445 @@
#!/bin/sh
# shellcheck shell=sh
ss_case_blocklist_format() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
SS_TMP_DIR="$TMP_DIR/tmp"
SS_DNSMASQ_DIR="$TMP_DIR/dnsmasq.d"
SS_BLOCKLIST_FILE="$SS_DNSMASQ_DIR/safeshield.blocklist"
ss_min_valid_line_count=0
ss_max_blocklist_file_size_kb=1024
ss_dnsmasq_sanity_check=0
ss_valid_line_count=0
mkdir -p "$SS_TMP_DIR" "$SS_DNSMASQ_DIR"
ss_should_stop() { return 1; }
ss_status_set() { :; }
ss_status_add_error() { :; }
log_info() { :; }
log_error() { :; }
log_warn() { :; }
ss_sync_path() { :; }
ss_blocklist_tmp_path() { printf '%s/.safeshield.blocklist.tmp.%s\n' "$SS_DNSMASQ_DIR" "$$"; }
ss_install_blocklist_atomic() { mv -f "$1" "$2"; }
DNSMASQ_PATCHED=1
ss_dnsmasq_safeshield_block_supported() { [ "$DNSMASQ_PATCHED" = '1' ]; }
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh" 'smartsafehub-block='
normalized="$(printf '%s\n' \
'address=/ads.example/#' \
'address=/legacy-v4.example/0.0.0.0' \
'address=/legacy-v6.example/::' \
'safeshield-block=/native.example/' | ss_normalize_domains)"
expected='ads.example
legacy-v4.example
legacy-v6.example
native.example'
ss_spec_assert_eq "$normalized" "$expected"
cat >"$SS_TMP_DIR/api.0.block.txt" <<'DATA'
ads.example
tracker.example
ads.example
DATA
cat >"$SS_TMP_DIR/api.1.block.txt" <<'DATA'
phishing.example
tracker.example
DATA
cat >"$SS_TMP_DIR/api.2.allow.txt" <<'DATA'
tracker.example
remote-allow.example
DATA
cat >"$SS_TMP_DIR/local.block.txt" <<'DATA'
remote-allow.example
local-block.example
DATA
cat >"$SS_TMP_DIR/local.allow.txt" <<'DATA'
local-block.example
DATA
ss_merge_lists
expected='safeshield-block=/ads.example/
safeshield-block=/phishing.example/
safeshield-block=/remote-allow.example/
server=/local-block.example/#
server=/tracker.example/#'
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" "$expected"
ss_spec_assert_eq "$(grep -c . "$SS_BLOCKLIST_FILE")" '5'
check_blocklist_rule_present 'ads.example'
ss_spec_assert_eq "$(find_test_domains 1)" 'ads.example'
rm -f "$SS_TMP_DIR"/api.*.block.txt "$SS_TMP_DIR"/api.*.allow.txt
printf '%s\n' 'example.com' >"$SS_TMP_DIR/api.0.block.txt"
printf '%s\n' 'good.example.com' >"$SS_TMP_DIR/api.1.allow.txt"
printf '%s\n' 'blocked.good.example.com' >"$SS_TMP_DIR/local.block.txt"
printf '%s\n' 'allowed.blocked.good.example.com' >"$SS_TMP_DIR/local.allow.txt"
ss_merge_lists
expected='safeshield-block=/blocked.good.example.com/
safeshield-block=/example.com/
server=/allowed.blocked.good.example.com/#
server=/good.example.com/#'
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" "$expected"
cat >"$SS_BLOCKLIST_FILE" <<'DATA'
address=/migrate-one.example/#
address=/migrate-two.example/0.0.0.0
server=/allowed.example/#
DATA
ss_reconcile_blocklist_file_for_dnsmasq "$SS_BLOCKLIST_FILE"
expected='safeshield-block=/migrate-one.example/
safeshield-block=/migrate-two.example/
server=/allowed.example/#'
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" "$expected"
DNSMASQ_PATCHED=0
ss_reconcile_blocklist_file_for_dnsmasq "$SS_BLOCKLIST_FILE"
expected='address=/migrate-one.example/#
address=/migrate-two.example/#
server=/allowed.example/#'
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" "$expected"
rm -f \
"$SS_TMP_DIR"/api.*.block.txt \
"$SS_TMP_DIR"/api.*.allow.txt \
"$SS_TMP_DIR"/local.block.txt \
"$SS_TMP_DIR"/local.allow.txt
printf '%s\n' 'fallback.example' >"$SS_TMP_DIR/api.0.block.txt"
ss_merge_lists
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" 'address=/fallback.example/#'
DNSMASQ_PATCHED=1
cat >"$SS_BLOCKLIST_FILE" <<'DATA'
address=/legacy.example/0.0.0.0
address=/legacy.example/::
DATA
check_blocklist_rule_present 'legacy.example'
cat >"$SS_BLOCKLIST_FILE" <<'DATA'
address=/sample-one.example/#
address=/sample-two.example/#
address=/sample-three.example/#
DATA
sampled="$(find_test_domains 2)"
expected_sample='sample-one.example
sample-two.example'
ss_spec_assert_eq "$sampled" "$expected_sample"
RULE_CHECK_COUNT=0
DNS_CHECK_COUNT=0
check_blocklist_rule_present() {
RULE_CHECK_COUNT=$((RULE_CHECK_COUNT + 1))
return 1
}
check_domain_blocked() {
DNS_CHECK_COUNT=$((DNS_CHECK_COUNT + 1))
return 0
}
check_blocklist_applied_multi_with_stats 2 2
ss_spec_assert_eq "$RULE_CHECK_COUNT" '0'
ss_spec_assert_eq "$DNS_CHECK_COUNT" '2'
)
ss_case_multi_artifact() (
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_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
mkdir -p "$SS_TMP_DIR"
ss_should_stop() { return 1; }
ss_status_set() { :; }
ss_status_add_error() { :; }
log_info() { :; }
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_write_resolve_payload() { printf '{}\n' >"$1"; }
ss_http_post_json() { printf '{}\n' >"$3"; }
ss_json_get_file() {
case "$2" in
'@.artifact.sources[0].download_url') printf '%s\n' 'https://example.invalid/hagezi.txt' ;;
'@.artifact.sources[0].action') printf '%s\n' 'block' ;;
'@.artifact.sources[0].id') printf '%s\n' 'hagezi-pro' ;;
'@.artifact.sources[0].sha256') printf '%s\n' "$HAGEZI_SHA" ;;
'@.artifact.sources[1].download_url') printf '%s\n' 'https://example.invalid/smartsafehub-block.txt' ;;
'@.artifact.sources[1].action') printf '%s\n' 'block' ;;
'@.artifact.sources[1].id') printf '%s\n' 'smartsafehub-pro' ;;
'@.artifact.sources[1].sha256') printf '%s\n' "$SMART_BLOCK_SHA" ;;
'@.artifact.sources[2].download_url') printf '%s\n' 'https://example.invalid/smartsafehub-allow.txt' ;;
'@.artifact.sources[2].action') printf '%s\n' 'allow' ;;
'@.artifact.sources[2].id') printf '%s\n' 'smartsafehub-allow' ;;
'@.artifact.sources[2].sha256') printf '%s\n' "$SMART_ALLOW_SHA" ;;
'@.artifact.sources[3].download_url' | '@.artifact.sources[16].download_url' | '@.artifact.sha256') : ;;
'@.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' ;;
'@.device.profile') printf '%s\n' 'test-profile' ;;
*) : ;;
esac
}
cat >"$TMP_DIR/hagezi.txt" <<'DATA'
address=/ads.example/#
address=/shared.example/#
DATA
cat >"$TMP_DIR/smartsafehub-block.txt" <<'DATA'
phishing.example
shared.example
DATA
printf '%s\n' 'shared.example' >"$TMP_DIR/smartsafehub-allow.txt"
HAGEZI_SHA="$(sha256sum "$TMP_DIR/hagezi.txt" | awk '{print $1}')"
SMART_BLOCK_SHA="$(sha256sum "$TMP_DIR/smartsafehub-block.txt" | awk '{print $1}')"
SMART_ALLOW_SHA="$(sha256sum "$TMP_DIR/smartsafehub-allow.txt" | awk '{print $1}')"
export HAGEZI_SHA SMART_BLOCK_SHA SMART_ALLOW_SHA
ss_resolve_artifact
expected_sources="0|block|hagezi-pro|https://example.invalid/hagezi.txt|$HAGEZI_SHA
1|block|smartsafehub-pro|https://example.invalid/smartsafehub-block.txt|$SMART_BLOCK_SHA
2|allow|smartsafehub-allow|https://example.invalid/smartsafehub-allow.txt|$SMART_ALLOW_SHA"
ss_spec_assert_eq "$(cat "$SS_RESOLVED_SOURCES")" "$expected_sources"
ss_http_get_file() {
case "$1" in
'https://example.invalid/hagezi.txt') cp "$TMP_DIR/hagezi.txt" "$2" ;;
'https://example.invalid/smartsafehub-block.txt') cp "$TMP_DIR/smartsafehub-block.txt" "$2" ;;
'https://example.invalid/smartsafehub-allow.txt') cp "$TMP_DIR/smartsafehub-allow.txt" "$2" ;;
*) return 1 ;;
esac
}
ss_download_api_artifacts
ss_cached_api_sources_available
ss_spec_assert_eq "$(cat "$SS_TMP_DIR/api.0.block.txt")" 'ads.example
shared.example'
ss_spec_assert_eq "$(cat "$SS_TMP_DIR/api.1.block.txt")" 'phishing.example
shared.example'
ss_spec_assert_eq "$(cat "$SS_TMP_DIR/api.2.allow.txt")" 'shared.example'
ss_json_get_file() {
case "$2" in
'@.artifact.sources[0].download_url') : ;;
'@.artifact.download_url') printf '%s\n' 'https://example.invalid/legacy.txt' ;;
'@.artifact.sha256') printf '%s\n' 'legacy-sha' ;;
*) : ;;
esac
}
ss_resolve_artifact_sources "$SS_API_RESPONSE"
ss_spec_assert_eq "$(cat "$SS_RESOLVED_SOURCES")" '0|block|legacy|https://example.invalid/legacy.txt|legacy-sha'
)
ss_case_performance_paths() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
SS_TMP_DIR="$TMP"
export SS_TMP_DIR
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
cat >"$TMP/api.0.block.txt" <<'EOF_BLOCK_0'
alpha.example
shared.example
EOF_BLOCK_0
cat >"$TMP/api.1.block.txt" <<'EOF_BLOCK_1'
beta.example
shared.example
EOF_BLOCK_1
ss_merge_sorted_api_sources block "$TMP/merged-blocks.txt"
expected='alpha.example
beta.example
shared.example'
ss_spec_assert_eq "$(cat "$TMP/merged-blocks.txt")" "$expected"
rm -f "$TMP"/api.*.block.txt
ss_merge_sorted_api_sources block "$TMP/empty-blocks.txt"
[ ! -s "$TMP/empty-blocks.txt" ]
BLOCKLIST="$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
STATISTICS_AGGREGATE="$SS_SPEC_ROOT/files/usr/lib/safeshield/statistics/30-aggregate.awk"
STATISTICS_OUTPUT="$SS_SPEC_ROOT/files/usr/lib/safeshield/statistics/50-output.awk"
ss_spec_assert_file_contains "$BLOCKLIST" 'sort -m -u "$@" >"$out"'
ss_spec_assert_file_contains "$STATISTICS_AGGREGATE" 'delete device_first_bucket[key]'
ss_spec_assert_file_contains "$STATISTICS_OUTPUT" 'device_first_hour = device_first_bucket[key] + 0'
ss_spec_assert_file_contains "$STATISTICS_OUTPUT" 'for (bucket = device_first_hour; bucket <= device_last_hour; bucket += 3600)'
)
ss_case_upgrade_required() (
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_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
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() { :; }
ss_status_add_warning() { :; }
log_info() { :; }
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_write_resolve_payload() { printf '{}\n' >"$1"; }
printf '%s\n' 'HTTP error 426' >"$TMP_DIR/uclient.log"
ss_spec_assert_eq "$(ss_http_status_from_uclient_log "$TMP_DIR/uclient.log")" '426'
command_exists() { [ "$1" = 'curl' ]; }
curl() {
out=''
while [ "$#" -gt 0 ]; do
if [ "$1" = '-o' ]; then
shift
out="$1"
fi
shift
done
[ -z "$out" ] || printf '%s\n' '{"code":"safeshield_upgrade_required"}' >"$out"
printf '%s' '426'
return 0
}
if ss_http_post_json 'https://example.invalid/resolve' "$SS_API_PAYLOAD" "$SS_API_RESPONSE"; then
return 1
fi
ss_spec_assert_eq "$SS_HTTP_STATUS" '426'
if ss_http_get_file 'https://example.invalid/artifact' "$SS_TMP_DIR/artifact.raw"; then
return 1
fi
ss_spec_assert_eq "$SS_HTTP_STATUS" '426'
command_exists() { return 1; }
RESOLVE_CALLS=0
ss_http_post_json() {
RESOLVE_CALLS=$((RESOLVE_CALLS + 1))
SS_HTTP_STATUS='426'
return 1
}
if ss_resolve_artifact; then
return 1
fi
ss_spec_assert_eq "$RESOLVE_CALLS" '1'
ss_spec_assert_eq "$SS_RESOLVE_ERROR_CODE" 'safeshield_upgrade_required'
ss_spec_assert_file_contains "$TMP_DIR/status" 'health_api_resolve=0'
ss_spec_assert_not_exists "$TMP_DIR/sleep"
printf '%s\n' '0|block|test-source|https://example.invalid/artifact.txt|' >"$SS_RESOLVED_SOURCES"
DOWNLOAD_CALLS=0
ss_http_get_file() {
DOWNLOAD_CALLS=$((DOWNLOAD_CALLS + 1))
SS_HTTP_STATUS='426'
return 1
}
if ss_download_api_artifacts; then
return 1
fi
ss_spec_assert_eq "$DOWNLOAD_CALLS" '1'
ss_spec_assert_eq "$SS_ARTIFACT_DOWNLOAD_ERROR_CODE" 'safeshield_upgrade_required'
ss_spec_assert_file_contains "$TMP_DIR/status" 'health_artifact_download=0'
ss_spec_assert_not_exists "$TMP_DIR/sleep"
)
ss_case_artifact_integrity() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
SS_TMP_DIR="$TMP/runtime"
SS_RESOLVED_SOURCES="$SS_TMP_DIR/resolved-sources.tsv"
SS_ARTIFACT_CACHE_STATE="$SS_TMP_DIR/artifact-sources.state"
ss_download_retry=2
ss_download_timeout=10
ss_max_blocklist_file_size_kb=1024
mkdir -p "$SS_TMP_DIR"
STATUS="$TMP/status"
: >"$STATUS"
ss_should_stop() { return 1; }
ss_status_set() { printf 'set %s %s\n' "$1" "${2:-}" >>"$STATUS"; }
ss_status_add_error() { printf 'error %s\n' "$1" >>"$STATUS"; }
ss_status_add_warning() { printf 'warning %s\n' "$1" >>"$STATUS"; }
log_info() { :; }
log_error() { :; }
log_ok() { :; }
sleep() { :; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
printf '%s\n' 'ads.example' 'shared.example' >"$TMP/source0.txt"
printf '%s\n' 'shared.example' >"$TMP/source1.txt"
SOURCE0_SHA="$(sha256sum "$TMP/source0.txt" | awk '{print $1}')"
printf '0|block|source-0|https://example.invalid/source0|%s\n' "$SOURCE0_SHA" >"$SS_RESOLVED_SOURCES"
printf '%s\n' '1|allow|source-1|https://example.invalid/source1|' >>"$SS_RESOLVED_SOURCES"
GET_CALLS=0
ss_http_get_file() {
GET_CALLS=$((GET_CALLS + 1))
SS_HTTP_STATUS=''
case "$1" in
https://example.invalid/source0)
if [ "$GET_CALLS" -eq 1 ]; then
return 1
fi
cp "$TMP/source0.txt" "$2"
;;
https://example.invalid/source1)
cp "$TMP/source1.txt" "$2"
;;
*) return 1 ;;
esac
}
ss_download_api_artifacts
ss_spec_assert_eq "$GET_CALLS" '3'
ss_cached_api_sources_available
ss_spec_assert_file_line "$STATUS" 'set health_artifact_download 1'
ss_spec_assert_file_line "$STATUS" 'set health_artifact_sha256 '
ss_spec_assert_file_line "$STATUS" 'warning artifact_sha256_partial'
ss_spec_assert_eq "$(cat "$SS_TMP_DIR/api.0.block.txt")" 'ads.example
shared.example'
ss_spec_assert_eq "$(cat "$SS_TMP_DIR/api.1.allow.txt")" 'shared.example'
: >"$STATUS"
printf '%s\n' '0|block|bad-sha|https://example.invalid/source0|0000000000000000000000000000000000000000000000000000000000000000' >"$SS_RESOLVED_SOURCES"
GET_CALLS=0
ss_http_get_file() {
GET_CALLS=$((GET_CALLS + 1))
SS_HTTP_STATUS=''
cp "$TMP/source0.txt" "$2"
}
! ss_download_api_artifacts
ss_spec_assert_file_line "$STATUS" 'set health_artifact_sha256 0'
ss_spec_assert_file_line "$STATUS" 'error artifact_sha256_mismatch'
ss_spec_assert_not_exists "$SS_ARTIFACT_CACHE_STATE"
: >"$STATUS"
command_exists() { return 1; }
! ss_verify_artifact_sha256 "$TMP/source0.txt" "$SOURCE0_SHA"
ss_spec_assert_file_line "$STATUS" 'set health_artifact_sha256 0'
ss_spec_assert_file_line "$STATUS" 'error sha256sum_not_found'
command_exists() { command -v "$1" >/dev/null 2>&1; }
: >"$STATUS"
printf '%s\n' '0|block|too-large|https://example.invalid/source0|' >"$SS_RESOLVED_SOURCES"
ss_max_blocklist_file_size_kb=1
ss_http_get_file() {
SS_HTTP_STATUS=''
cp "$TMP/source0.txt" "$2"
}
du() { printf '2\t%s\n' "$2"; }
! ss_download_api_artifacts
ss_spec_assert_file_line "$STATUS" 'set health_artifact_download 0'
ss_spec_assert_file_line "$STATUS" 'error artifact_too_large'
ss_spec_assert_not_exists "$SS_ARTIFACT_CACHE_STATE"
)
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# shellcheck shell=sh
SS_SPEC_ROOT="${SS_SPEC_ROOT:-$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)}"
ss_spec_tmpdir() {
mktemp -d
}
ss_spec_assert_eq() {
[ "$1" = "$2" ]
}
ss_spec_assert_ne() {
[ "$1" != "$2" ]
}
ss_spec_assert_file_contains() {
grep -Fq -- "$2" "$1"
}
ss_spec_assert_file_not_contains() {
! grep -Fq -- "$2" "$1"
}
ss_spec_assert_file_line() {
grep -Fxq -- "$2" "$1"
}
ss_spec_assert_exists() {
[ -e "$1" ]
}
ss_spec_assert_not_exists() {
[ ! -e "$1" ]
}
ss_spec_assert_nonempty() {
[ -s "$1" ]
}
+449
View File
@@ -0,0 +1,449 @@
#!/bin/sh
# shellcheck shell=sh
ss_case_utils() (
set -eu
PKG_NAME='safeshield-test'
export PKG_NAME
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh"
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
! is_valid_integer "$value"
done
is_greater_equal 2.90 2.90
is_greater_equal 2.91 2.90
! is_greater_equal 2.79 2.80
)
ss_case_config_validation() (
set -eu
PKG_NAME='safeshield-test'
export PKG_NAME
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh"
WARNINGS=''
CONFIG_GET_VALUE=''
log_warn() { :; }
ss_status_add_warning() {
WARNINGS="${WARNINGS}${WARNINGS:+ }$1"
}
config_get() {
eval "$1='${CONFIG_GET_VALUE}'"
}
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/config.sh"
CONFIG_GET_VALUE='configured'
ss_spec_assert_eq "$(ss_config_get config enabled default)" 'configured'
CONFIG_GET_VALUE=''
ss_spec_assert_eq "$(ss_config_get config enabled default)" 'default'
ss_download_timeout='invalid'
ss_download_retry='-1'
ss_max_blocklist_file_size_kb='1.5'
ss_min_valid_line_count=''
ss_pause_timeout='invalid'
ss_boot_start_delay_s='-5'
ss_statistics_snapshot_interval_s='9'
ss_statistics_retention_hours='169'
ss_compress_blocklist='2'
ss_initial_dnsmasq_restart='yes'
ss_dnsmasq_sanity_check='no'
ss_apply_local_overrides='2'
ss_statistics_enabled='off'
WARNINGS=''
ss_validate_config
ss_spec_assert_eq "$ss_download_timeout" '10'
ss_spec_assert_eq "$ss_download_retry" '3'
ss_spec_assert_eq "$ss_max_blocklist_file_size_kb" '30000'
ss_spec_assert_eq "$ss_min_valid_line_count" '3000'
ss_spec_assert_eq "$ss_pause_timeout" '20'
ss_spec_assert_eq "$ss_boot_start_delay_s" '30'
ss_spec_assert_eq "$ss_statistics_snapshot_interval_s" '60'
ss_spec_assert_eq "$ss_statistics_retention_hours" '168'
ss_spec_assert_eq "$ss_compress_blocklist" '0'
ss_spec_assert_eq "$ss_initial_dnsmasq_restart" '0'
ss_spec_assert_eq "$ss_dnsmasq_sanity_check" '1'
ss_spec_assert_eq "$ss_apply_local_overrides" '1'
ss_spec_assert_eq "$ss_statistics_enabled" '1'
for code in \
invalid_download_timeout \
invalid_download_retry \
invalid_max_blocklist_file_size_kb \
invalid_min_valid_line_count \
invalid_pause_timeout \
invalid_boot_start_delay_s \
invalid_statistics_snapshot_interval_s \
invalid_statistics_retention_hours \
invalid_compress_blocklist \
invalid_initial_dnsmasq_restart \
invalid_dnsmasq_sanity_check \
invalid_apply_local_overrides \
invalid_statistics_enabled; do
case " $WARNINGS " in
*" $code "*) ;;
*) return 1 ;;
esac
done
ss_download_timeout='1'
ss_download_retry='0'
ss_max_blocklist_file_size_kb='1'
ss_min_valid_line_count='0'
ss_pause_timeout='0'
ss_boot_start_delay_s='0'
ss_statistics_snapshot_interval_s='10'
ss_statistics_retention_hours='168'
ss_compress_blocklist='1'
ss_initial_dnsmasq_restart='1'
ss_dnsmasq_sanity_check='0'
ss_apply_local_overrides='0'
ss_statistics_enabled='0'
WARNINGS=''
ss_validate_config
[ -z "$WARNINGS" ]
ss_spec_assert_eq "$ss_statistics_snapshot_interval_s" '10'
ss_spec_assert_eq "$ss_statistics_retention_hours" '168'
)
ss_case_dnsmasq_version() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
PKG_NAME='safeshield'
export PKG_NAME
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh"
DNSMASQ_FEATURE_HEALTH=''
ss_status_set() {
[ "$1" = 'health_dnsmasq_features' ] && DNSMASQ_FEATURE_HEALTH="$2"
return 0
}
log_error() { :; }
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/dns.sh"
ss_spec_assert_file_not_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/dns.sh" 'smartsafehub'
make_dnsmasq() {
version="$1"
feature_rc="${2:-0}"
cat >"$TMP_DIR/dnsmasq" <<SCRIPT
#!/bin/sh
case "\${1:-}" in
--version)
printf '%s\n' 'Dnsmasq version ${version} Copyright (c) 2000-2026 Simon Kelley'
exit 0
;;
--test)
exit ${feature_rc}
;;
esac
exit 0
SCRIPT
chmod +x "$TMP_DIR/dnsmasq"
}
PATH="$TMP_DIR:$PATH"
export PATH
make_dnsmasq '2.93'
ss_spec_assert_eq "$(ss_dnsmasq_version)" '2.93'
ss_require_supported_dnsmasq
make_dnsmasq '2.94'
ss_require_supported_dnsmasq
make_dnsmasq '2.93rc1'
ss_spec_assert_eq "$(ss_dnsmasq_version)" '2.93'
ss_require_supported_dnsmasq
make_dnsmasq '2.92'
! ss_require_supported_dnsmasq
ss_spec_assert_eq "$(ss_dnsmasq_check_error)" 'dnsmasq_version_unsupported'
make_dnsmasq '2.93' 1
ss_require_supported_dnsmasq
ss_spec_assert_eq "$(ss_dnsmasq_check_error)" ''
ss_spec_assert_eq "$DNSMASQ_FEATURE_HEALTH" '0'
cat >"$TMP_DIR/dnsmasq" <<'SCRIPT'
#!/bin/sh
printf '%s\n' 'unexpected version output'
SCRIPT
chmod +x "$TMP_DIR/dnsmasq"
! ss_require_supported_dnsmasq
ss_spec_assert_eq "$(ss_dnsmasq_check_error)" 'dnsmasq_version_unknown'
rm -f "$TMP_DIR/dnsmasq"
ORIGINAL_PATH="$PATH"
PATH="$TMP_DIR"
! ss_require_supported_dnsmasq
PATH="$ORIGINAL_PATH"
ss_spec_assert_eq "$(ss_dnsmasq_check_error)" 'dnsmasq_binary_not_found'
)
ss_case_identity() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
SS_IDENTITY_DIR="$TMP_DIR/identity"
SS_IDENTITY_FILE="$SS_IDENTITY_DIR/identity.env"
export SS_IDENTITY_DIR SS_IDENTITY_FILE
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/identity.sh"
ss_spec_assert_eq "$(ss_identity_normalize_mac 'MAC=AA:BB:CC:DD:EE:FF')" 'aa:bb:cc:dd:ee:ff'
ss_identity_valid_mac 'aa:bb:cc:dd:ee:ff'
for mac in '' '00:00:00:00:00:00' 'ff:ff:ff:ff:ff:ff' 'aa:bb:cc:dd:ee'; do
! ss_identity_valid_mac "$mac"
done
ss_spec_assert_eq "$(ss_identity_hex_to_mac 'AABBCCDDEEFF')" 'aa:bb:cc:dd:ee:ff'
ss_spec_assert_eq "$(ss_identity_profile_code 'iptime,ax3000sm')" 'iptime_ax3000sm'
ss_spec_assert_eq "$(ss_identity_profile_code 'glinet,gl-mt300n-v2')" 'gl_mt300n_v2'
ss_spec_assert_eq "$(ss_identity_profile_code 'xiaomi,mi-router-ax3000t')" 'xiaomi_mi_router_ax3000t'
ss_spec_assert_eq "$(ss_identity_profile_code 'smartsafehub,router')" 'smartsafehub'
ss_spec_assert_eq "$(ss_identity_device_code_from_board 'iptime,ax3000sm')" 'iptime-ax3000sm'
ss_spec_assert_eq "$(ss_identity_device_code_from_board 'glinet,gl-mt300n-v2')" 'gl-mt300n-v2'
ss_spec_assert_eq "$(ss_identity_device_code_from_board 'xiaomi,mi-router-ax3000t')" 'xiaomi-ax3000t'
ss_spec_assert_eq "$(ss_identity_device_code_from_board 'xiaomi,mi-router-ax3000t-ubootmod')" 'xiaomi-ax3000t'
! ss_identity_device_code_from_board 'vendor,unsupported-router'
ss_identity_valid_device_code 'xiaomi-ax3000t'
! ss_identity_valid_device_code 'Xiaomi AX3000T'
SS_SMARTSAFEHUB_FIRMWARE_JSON="$TMP_DIR/firmware.json"
export SS_SMARTSAFEHUB_FIRMWARE_JSON
printf '%s\n' '{"device_code":"xiaomi-ax3000t"}' >"$SS_SMARTSAFEHUB_FIRMWARE_JSON"
MOCK_DEVICE_CODE='xiaomi-ax3000t'
jsonfilter() { printf '%s\n' "$MOCK_DEVICE_CODE"; }
ss_identity_board_name() { printf '%s' 'iptime,ax3000sm'; }
ss_identity_refresh_device_code
ss_spec_assert_eq "$SS_DEVICE_CODE" 'xiaomi-ax3000t'
ss_spec_assert_eq "$SS_DEVICE_CODE_SOURCE" 'smartsafehub_firmware'
MOCK_DEVICE_CODE='invalid device code'
ss_identity_refresh_device_code
ss_spec_assert_eq "$SS_DEVICE_CODE" 'iptime-ax3000sm'
ss_spec_assert_eq "$SS_DEVICE_CODE_SOURCE" 'board'
rm -f "$SS_SMARTSAFEHUB_FIRMWARE_JSON"
ss_identity_board_name() { printf '%s' 'vendor,unsupported-router'; }
ss_identity_refresh_device_code
ss_spec_assert_eq "$SS_DEVICE_CODE" ''
ss_spec_assert_eq "$SS_DEVICE_CODE_SOURCE" 'unknown'
ss_identity_board_name() { printf '%s' 'iptime,ax3000sm'; }
ss_identity_try_mtd_factory_mac() { printf '%s' 'aa:bb:cc:dd:ee:ff|mtd:Factory:0x4:mac'; }
ss_identity_detect_primary_mac() { return 1; }
ss_identity_compute_physical 'ipTIME AX3000SM' 'aarch64'
ss_spec_assert_eq "$SS_IDENTITY_PROVIDER" 'factory_mac'
ss_spec_assert_eq "$SS_IDENTITY_SOURCE" 'mtd:Factory:0x4:mac'
ss_spec_assert_eq "$SS_IDENTITY_STRENGTH" 'hardware_soft'
ss_spec_assert_eq "$SS_IDENTITY_PROFILE" 'iptime_ax3000sm'
ss_identity_valid_sha256 "$SS_IDENTITY_VALUE_SHA256"
ss_identity_valid_sha256 "$SS_PHYSICAL_FINGERPRINT"
SS_INSTALLATION_ID='11111111-2222-3333-4444-555555555555'
SS_INSTALLATION_SECRET='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
SS_IDENTITY_CREATED_AT='1788090000'
SS_IDENTITY_UPDATED_AT='1788090001'
ss_identity_write_env
ss_spec_assert_nonempty "$SS_IDENTITY_FILE"
identity_mode="$(LC_ALL=C ls -ld "$SS_IDENTITY_FILE" | awk '{ print substr($1, 2, 9) }')"
ss_spec_assert_eq "$identity_mode" 'rw-------'
expected_fingerprint="$SS_PHYSICAL_FINGERPRINT"
SS_PHYSICAL_FINGERPRINT=''
SS_IDENTITY_PROVIDER=''
SS_IDENTITY_SOURCE=''
SS_IDENTITY_STRENGTH=''
SS_IDENTITY_PROFILE=''
SS_INSTALLATION_ID=''
ss_identity_load
ss_spec_assert_eq "$SS_PHYSICAL_FINGERPRINT" "$expected_fingerprint"
ss_spec_assert_eq "$SS_IDENTITY_PROVIDER" 'factory_mac'
ss_spec_assert_eq "$SS_INSTALLATION_ID" '11111111-2222-3333-4444-555555555555'
SYNCED=0
STATUS_SYNCED=0
ss_identity_sync_uci() { SYNCED=1; }
ss_identity_status_set() { STATUS_SYNCED=1; }
ss_identity_create() { return 97; }
ss_identity_ensure 'ipTIME AX3000SM' 'aarch64'
ss_spec_assert_eq "$SYNCED" '1'
ss_spec_assert_eq "$STATUS_SYNCED" '1'
)
ss_case_resolve_payload() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
ss_detect_device_model() { printf '%s' 'Test Router'; }
ss_detect_device_vendor() { printf '%s' 'TestVendor'; }
ss_detect_device_arch() { printf '%s' 'test_arch'; }
ss_detect_device_memory_mb() { printf '%s' '128'; }
SS_PHYSICAL_FINGERPRINT='test-fingerprint'
SS_FINGERPRINT_VERSION='1'
SS_IDENTITY_PROVIDER='factory_mac'
SS_IDENTITY_SOURCE='test-source'
SS_IDENTITY_STRENGTH='hardware_soft'
SS_IDENTITY_PROFILE='test-profile'
SS_DEVICE_CODE='test-router'
SS_DEVICE_CODE_SOURCE='board'
SS_INSTALLATION_ID='test-installation'
ss_identity_ensure() { return 0; }
is_valid_integer() {
case "$1" in
'' | *[!0-9]*) return 1 ;;
*) return 0 ;;
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"
payload="$TMP_DIR/resolve-request.json"
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"'
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"'
ss_spec_assert_file_contains "$payload" '"identity_source": "test-source"'
ss_spec_assert_file_contains "$payload" '"identity_strength": "hardware_soft"'
ss_spec_assert_file_contains "$payload" '"identity_profile": "test-profile"'
ss_spec_assert_file_contains "$payload" '"device_code": "test-router"'
ss_spec_assert_file_contains "$payload" '"device_code_source": "board"'
ss_spec_assert_file_contains "$payload" '"installation_id": "test-installation"'
ss_spec_assert_file_contains "$payload" '"vendor": "TestVendor"'
ss_spec_assert_file_contains "$payload" '"model": "Test Router"'
ss_spec_assert_file_contains "$payload" '"arch": "test_arch"'
ss_spec_assert_file_contains "$payload" '"memory_mb": 128'
ss_detect_device_memory_mb() { printf '%s' 'invalid-memory'; }
invalid_memory_payload="$TMP_DIR/resolve-request-invalid-memory.json"
ss_write_resolve_payload "$invalid_memory_payload"
ss_spec_assert_file_contains "$invalid_memory_payload" '"memory_mb": 0'
SS_VERSION_FILE="$TMP_DIR/missing-version"
export SS_VERSION_FILE
fallback_payload="$TMP_DIR/resolve-request-fallback.json"
ss_write_resolve_payload "$fallback_payload"
ss_spec_assert_file_contains "$fallback_payload" ' "safeshield_version": "unknown"'
! grep -Eq '^ "safeshield_version"' "$fallback_payload"
)
ss_case_rpcd_contract() (
set -eu
ENTRY="$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield.uc"
ACL="$SS_SPEC_ROOT/files/usr/share/rpcd/acl.d/safeshield.json"
MAKEFILE="$SS_SPEC_ROOT/Makefile"
STATISTICS_MODULE="$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield/statistics.uc"
[ -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
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
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'
ss_spec_assert_file_contains "$STATISTICS_MODULE" 'effective_snapshot_interval_s: to_int(data.snapshot_interval_s, snapshot_interval_s)'
)
ss_case_status_state() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
RUNNING_STATUS_FILE="$TMP_DIR/status.json"
SS_DNSMASQ_DIR="$TMP_DIR/dnsmasq.d"
SS_BLOCKLIST_FILE="$SS_DNSMASQ_DIR/safeshield.blocklist"
SS_PREV_BLOCKLIST_GZ="$TMP_DIR/previous.gz"
export RUNNING_STATUS_FILE SS_DNSMASQ_DIR SS_BLOCKLIST_FILE SS_PREV_BLOCKLIST_GZ
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh"
CALLS="$TMP_DIR/calls"
: >"$CALLS"
record() { printf '%s\t%s\t%s\n' "$1" "${2:-}" "${3:-}" >>"$CALLS"; }
ss_status_set() { record set "$1" "$2"; }
ss_status_add_error() { record error "$1"; }
ss_status_apply() {
record apply "$1" "${2:-}"
return 0
}
ss_now() { printf '%s' '1000'; }
is_valid_integer() {
case "$1" in '' | *[!0-9]*) return 1 ;; esac
[ "$1" -ge 0 ] 2>/dev/null
}
ss_status_mark_failure 'artifact_download_failed'
grep -F "$(printf 'set\tstatus\terror')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tlast_failure\t1000')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tlast_result\terror')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tlast_error_code\tartifact_download_failed')" "$CALLS" >/dev/null
grep -F "$(printf 'error\tartifact_download_failed')" "$CALLS" >/dev/null
: >"$CALLS"
ss_status_mark_success 300
grep -F "$(printf 'apply\tclear_messages')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tstatus\tready')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tstage\tdone')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tlast_success\t1000')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tnext_refresh_at\t1300')" "$CALLS" >/dev/null
: >"$CALLS"
ss_status_mark_success invalid
! grep -F "$(printf 'set\tnext_refresh_at')" "$CALLS" >/dev/null
: >"$CALLS"
ss_status_reset_health_fields
for key in health_dnsmasq_binary health_dns_runtime health_artifact_download dnsmasq_min_version; do
grep -F "$(printf 'set\t%s\t' "$key")" "$CALLS" >/dev/null
done
: >"$CALLS"
ss_status_reset_blocklist_fields
for key in blocklist_installed blocklist_file_size_kb blocklist_verification_ok blocklist_backup_available; do
grep -F "$(printf 'set\t%s\t' "$key")" "$CALLS" >/dev/null
done
: >"$CALLS"
ss_status_reset_artifact_fields
for key in license_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"
printf '%s\n' 'address=/ads.example/#' >"$TMP_DIR/new.blocklist"
ss_sync_path() { :; }
ss_install_blocklist_atomic "$TMP_DIR/new.blocklist" "$SS_BLOCKLIST_FILE"
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" 'address=/ads.example/#'
command_exists() { command -v "$1" >/dev/null 2>&1; }
ss_export_existing_blocklist
ss_spec_assert_nonempty "$SS_PREV_BLOCKLIST_GZ"
rm -f "$SS_BLOCKLIST_FILE"
ss_restore_previous_blocklist
ss_spec_assert_eq "$(cat "$SS_BLOCKLIST_FILE")" 'address=/ads.example/#'
)
ss_case_status_version() (
set -eu
make_version="$(sed -n 's/^PKG_VERSION:=//p' "$SS_SPEC_ROOT/Makefile")"
make_release="$(sed -n 's/^PKG_RELEASE:=//p' "$SS_SPEC_ROOT/Makefile")"
init_version="$(sed -n "s/^readonly PKG_VERSION='\([^']*\)'/\1/p" "$SS_SPEC_ROOT/files/etc/init.d/safeshield")"
[ -n "$make_version" ]
[ -n "$make_release" ]
[ -n "$init_version" ]
ss_spec_assert_eq "$init_version" "${make_version}-r${make_release}"
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/etc/init.d/safeshield" "printf '%s\\n' \"\$PKG_VERSION\""
ss_spec_assert_file_contains "$SS_SPEC_ROOT/Makefile" "echo '\$(PKG_VERSION)-r\$(PKG_RELEASE)' > \$(1)/usr/lib/safeshield/version"
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield/core.uc" 'fs.readfile(`/usr/lib/${PKG_NAME}/version`)'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield/status.uc" 'version: PKG_VERSION,'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'safeshield_upgrade_required'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'failure_code="$(ss_resolve_error_code)"'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'failure_code="$(ss_artifact_download_error_code)"'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'if [ "$failure_code" != "safeshield_upgrade_required" ]; then'
)
+535
View File
@@ -0,0 +1,535 @@
#!/bin/sh
# shellcheck shell=sh
ss_case_refresh_state_machine() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
CORE="$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh"
HARNESS="$TMP/core-state-machine.sh"
sed -n '/^safeshield_apply_local_rules() {/,${p;}' "$CORE" >"$HARNESS"
# shellcheck disable=SC1090
. "$HARNESS"
SS_TMP_DIR="$TMP/runtime"
SS_DNSMASQ_DIR="$TMP/dnsmasq.d"
SS_BLOCKLIST_FILE="$SS_DNSMASQ_DIR/safeshield.blocklist"
SS_INACTIVE_BLOCKLIST_FILE="$SS_TMP_DIR/inactive.blocklist"
SS_PREV_BLOCKLIST_GZ="$TMP/previous.gz"
mkdir -p "$SS_TMP_DIR" "$SS_DNSMASQ_DIR"
CALLS="$TMP/calls"
: >"$CALLS"
record() { printf '%s\n' "$*" >>"$CALLS"; }
log_info() { :; }
log_warn() { :; }
log_error() { :; }
log_ok() { :; }
ss_status_prepare_refresh() { record status_prepare_refresh; }
ss_status_set() { record "status_set $1 ${2:-}"; }
ss_status_set_now() { record "status_set_now $1"; }
ss_status_add_error() { record "status_error $1"; }
ss_status_mark_failure() { record "failure $1"; }
ss_status_mark_success() { record "success $1"; }
ss_load_config() {
ss_enabled=1
export ss_initial_dnsmasq_restart=0
ss_apply_local_overrides=1
return 0
}
ss_config_get() {
case "$2" in
refresh_interval_s) printf '%s\n' '300' ;;
*) printf '%s\n' "${3:-}" ;;
esac
}
is_valid_integer() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac }
ss_sync_detected_device_config() { record sync_device_config; }
ss_identity_ensure() {
record identity_ensure
[ "${FAIL_STAGE:-}" != 'identity' ]
}
ss_detect_device_model() { printf '%s\n' 'Test Router'; }
ss_detect_device_arch() { printf '%s\n' 'test-arch'; }
ss_should_stop() { return 1; }
ss_mkdirs() {
record mkdirs
[ "${FAIL_STAGE:-}" != 'mkdir' ]
}
ss_clean_tmp() { record clean_tmp; }
ss_require_supported_dnsmasq() {
record require_dnsmasq
[ "${FAIL_STAGE:-}" != 'dnsmasq_version' ]
}
ss_dnsmasq_check_error() { printf '%s\n' 'dnsmasq_version_unsupported'; }
ss_ensure_dnsmasq_confdir() {
record ensure_confdir
[ "${FAIL_STAGE:-}" != 'confdir' ]
}
ss_export_existing_blocklist() {
record export_blocklist
printf '%s\n' 'backup' >"$SS_PREV_BLOCKLIST_GZ"
return 0
}
DNSMASQ_CALL=0
dnsmasq_restart() {
DNSMASQ_CALL=$((DNSMASQ_CALL + 1))
record "dnsmasq_restart $DNSMASQ_CALL"
[ "${FAIL_STAGE:-}" != 'restart' ]
}
ss_resolve_artifact() {
record resolve_artifact
[ "${FAIL_STAGE:-}" != 'resolve' ]
}
ss_resolve_error_code() { printf '%s\n' "${RESOLVE_ERROR_CODE:-artifact_resolve_failed}"; }
ss_download_api_artifacts() {
record download_artifacts
[ "${FAIL_STAGE:-}" != 'download' ]
}
ss_artifact_download_error_code() { printf '%s\n' "${DOWNLOAD_ERROR_CODE:-artifact_download_failed}"; }
ss_prepare_local_rule_files() {
record prepare_local_rules
[ "${FAIL_STAGE:-}" != 'local_rules' ]
}
ss_merge_lists() {
record merge_lists
[ "${FAIL_STAGE:-}" != 'merge' ]
}
ss_install_blocklist() {
record install_blocklist
[ "${FAIL_STAGE:-}" != 'install' ]
}
check_dns_runtime() {
record check_dns_runtime
[ "${FAIL_STAGE:-}" != 'runtime' ]
}
check_blocklist_applied_multi_with_stats() {
record "verify_blocklist $*"
[ "${FAIL_STAGE:-}" != 'verify' ]
}
ss_restore_and_restart() { record restore_and_restart; }
ss_refresh_lock_open() {
record lock_open
[ "${LOCK_AVAILABLE:-1}" = '1' ]
}
ss_refresh_lock_close() { record lock_close; }
ss_local_rules_fingerprint_from_tmp() { printf '%s\n' 'fingerprint-new'; }
ss_local_rules_state_write() { record "local_state_write $1"; }
ss_abort_refresh() {
record abort_refresh
return 130
}
assert_refresh_failure() {
stage="$1"
code="$2"
expect_restore="$3"
FAIL_STAGE="$stage"
RESOLVE_ERROR_CODE='artifact_resolve_failed'
DOWNLOAD_ERROR_CODE='artifact_download_failed'
DNSMASQ_CALL=0
: >"$CALLS"
if safeshield_force_download; then
return 1
fi
ss_spec_assert_file_line "$CALLS" "failure $code"
ss_spec_assert_file_line "$CALLS" 'lock_close'
if [ "$expect_restore" = '1' ]; then
ss_spec_assert_file_line "$CALLS" 'restore_and_restart'
else
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
fi
}
FAIL_STAGE=''
LOCK_AVAILABLE=1
: >"$CALLS"
safeshield_force_download
ss_spec_assert_file_line "$CALLS" 'status_set stage resolve_api'
ss_spec_assert_file_line "$CALLS" 'status_set stage download_artifact'
ss_spec_assert_file_line "$CALLS" 'status_set stage merge'
ss_spec_assert_file_line "$CALLS" 'status_set stage install'
ss_spec_assert_file_line "$CALLS" 'status_set stage restart_dnsmasq'
ss_spec_assert_file_line "$CALLS" 'status_set stage runtime_check'
ss_spec_assert_file_line "$CALLS" 'status_set stage blocklist_verify'
ss_spec_assert_file_line "$CALLS" 'success 300'
ss_spec_assert_file_line "$CALLS" 'local_state_write fingerprint-new'
ss_spec_assert_file_line "$CALLS" 'lock_close'
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
assert_refresh_failure merge merge_failed 1
assert_refresh_failure install install_failed 1
assert_refresh_failure restart dnsmasq_restart_failed 1
assert_refresh_failure runtime dns_runtime_check_failed 1
assert_refresh_failure verify blocklist_verification_failed 1
FAIL_STAGE='resolve'
RESOLVE_ERROR_CODE='safeshield_upgrade_required'
: >"$CALLS"
! safeshield_force_download
ss_spec_assert_file_line "$CALLS" 'failure safeshield_upgrade_required'
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
FAIL_STAGE='download'
DOWNLOAD_ERROR_CODE='safeshield_upgrade_required'
: >"$CALLS"
! safeshield_force_download
ss_spec_assert_file_line "$CALLS" 'failure safeshield_upgrade_required'
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
FAIL_STAGE=''
LOCK_AVAILABLE=0
: >"$CALLS"
safeshield_force_download
ss_spec_assert_file_line "$CALLS" 'lock_open'
! grep -F 'status_prepare_refresh' "$CALLS" >/dev/null
)
ss_case_local_apply_state_machine() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
CORE="$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh"
HARNESS="$TMP/local-apply.sh"
sed -n '/^safeshield_apply_local_rules() {/,/^safeshield_force_download() {/p' "$CORE" | sed '$d' >"$HARNESS"
# shellcheck disable=SC1090
. "$HARNESS"
SS_TMP_DIR="$TMP/runtime"
SS_DNSMASQ_DIR="$TMP/dnsmasq.d"
SS_BLOCKLIST_FILE="$SS_DNSMASQ_DIR/safeshield.blocklist"
SS_PREV_BLOCKLIST_GZ="$TMP/previous.gz"
SS_INACTIVE_BLOCKLIST_FILE="$SS_TMP_DIR/inactive.blocklist"
mkdir -p "$SS_TMP_DIR" "$SS_DNSMASQ_DIR"
CALLS="$TMP/calls"
: >"$CALLS"
record() { printf '%s\n' "$*" >>"$CALLS"; }
log_info() { :; }
log_warn() { :; }
log_error() { :; }
log_ok() { :; }
sleep() { :; }
ss_load_config() {
ss_enabled="${MOCK_ENABLED:-1}"
export ss_apply_local_overrides="${MOCK_OVERRIDES:-1}"
return 0
}
ss_mkdirs() { return 0; }
ss_refresh_lock_open_wait() {
record "lock_wait $1"
return "${LOCK_RC:-0}"
}
ss_refresh_lock_close() { record lock_close; }
ss_cached_api_sources_available() { [ "${CACHE_AVAILABLE:-1}" = '1' ]; }
ss_status_set() { record "status_set $1 ${2:-}"; }
ss_status_set_now() { record "status_set_now $1"; }
ss_status_add_error() { record "status_error $1"; }
ss_prepare_local_rule_files() {
record prepare_local_rules
[ "${LOCAL_FAIL:-}" != 'local_rules' ]
}
ss_local_rules_fingerprint_from_tmp() { printf '%s\n' "${NEW_FINGERPRINT:-fingerprint-new}"; }
ss_local_rules_state_read() { [ -n "${APPLIED_FINGERPRINT:-}" ] && printf '%s\n' "$APPLIED_FINGERPRINT"; }
ss_local_rules_state_write() { record "local_state_write $1"; }
ss_require_supported_dnsmasq() { [ "${LOCAL_FAIL:-}" != 'dnsmasq_version' ]; }
ss_dnsmasq_check_error() { printf '%s\n' 'dnsmasq_version_unsupported'; }
ss_ensure_dnsmasq_confdir() {
record ensure_confdir
[ "${LOCAL_FAIL:-}" != 'confdir' ]
}
ss_export_existing_blocklist() {
printf '%s\n' backup >"$SS_PREV_BLOCKLIST_GZ"
return 0
}
ss_merge_lists() {
record merge_lists
[ "${LOCAL_FAIL:-}" != 'merge' ]
}
dnsmasq_restart() {
record dnsmasq_restart
[ "${LOCAL_FAIL:-}" != 'restart' ]
}
check_dns_runtime() {
record check_dns_runtime
[ "${LOCAL_FAIL:-}" != 'runtime' ]
}
check_blocklist_applied_multi_with_stats() {
record "verify_blocklist $*"
[ "${LOCAL_FAIL:-}" != 'verify' ]
}
ss_restore_and_restart() { record restore_and_restart; }
ss_mark_local_apply_failure() { record "local_failure $1"; }
ss_mark_local_apply_success() { record local_success; }
FALLBACK_RC=0
safeshield_force_download() {
record fallback_refresh
return "$FALLBACK_RC"
}
CACHE_AVAILABLE=0
LOCK_RC=0
LOCAL_FAIL=''
: >"$CALLS"
safeshield_apply_local_rules
ss_spec_assert_file_line "$CALLS" 'fallback_refresh'
ss_spec_assert_file_line "$CALLS" 'lock_close'
CACHE_AVAILABLE=1
LOCK_RC=1
: >"$CALLS"
! safeshield_apply_local_rules
ss_spec_assert_file_line "$CALLS" 'local_failure local_apply_lock_timeout'
LOCK_RC=130
: >"$CALLS"
set +e
safeshield_apply_local_rules
rc=$?
set -e
ss_spec_assert_eq "$rc" '130'
! grep -F 'local_failure' "$CALLS" >/dev/null
LOCK_RC=0
APPLIED_FINGERPRINT='fingerprint-new'
NEW_FINGERPRINT='fingerprint-new'
printf '%s\n' 'safeshield-block=/cached.example/' >"$SS_BLOCKLIST_FILE"
: >"$CALLS"
safeshield_apply_local_rules
ss_spec_assert_file_line "$CALLS" 'status_set stage done'
! grep -Fx 'merge_lists' "$CALLS" >/dev/null
! grep -Fx 'dnsmasq_restart' "$CALLS" >/dev/null
APPLIED_FINGERPRINT='fingerprint-old'
NEW_FINGERPRINT='fingerprint-new'
for scenario in merge restart runtime verify; do
LOCAL_FAIL="$scenario"
: >"$CALLS"
! safeshield_apply_local_rules
case "$scenario" in
merge) code='local_merge_failed' ;;
restart) code='local_dnsmasq_restart_failed' ;;
runtime) code='local_dns_runtime_check_failed' ;;
verify) code='local_blocklist_verification_failed' ;;
esac
ss_spec_assert_file_line "$CALLS" "local_failure $code"
ss_spec_assert_file_line "$CALLS" 'restore_and_restart'
ss_spec_assert_file_line "$CALLS" 'lock_close'
done
LOCAL_FAIL=''
: >"$CALLS"
safeshield_apply_local_rules
ss_spec_assert_file_line "$CALLS" 'merge_lists'
ss_spec_assert_file_line "$CALLS" 'dnsmasq_restart'
ss_spec_assert_file_line "$CALLS" 'check_dns_runtime'
ss_spec_assert_file_line "$CALLS" 'verify_blocklist 5 3'
ss_spec_assert_file_line "$CALLS" 'local_state_write fingerprint-new'
ss_spec_assert_file_line "$CALLS" 'local_success'
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
)
ss_case_refresh_locking() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
RUNNING_STATUS_FILE="$TMP/status.json"
SS_REFRESH_LOCK="$TMP/safeshield-refresh.lock"
SS_REFRESH_LOCK_FD=9
MOCK_FLOCK_DIR="$TMP/mock-flock.lock"
# Keep this regression test portable on development hosts such as macOS,
# where flock(1) is not installed by default. The production code still
# invokes flock -n/-u; this shim emulates those operations with atomic
# mkdir/rmdir so contention, release, timeout, and termination paths are
# exercised without depending on a host-specific flock binary.
flock() {
case "${1:-}" in
-n)
mkdir "$MOCK_FLOCK_DIR" 2>/dev/null
;;
-u)
rmdir "$MOCK_FLOCK_DIR" 2>/dev/null || true
return 0
;;
*)
return 2
;;
esac
}
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh"
is_valid_integer() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac }
ss_should_stop() { return 1; }
mkdir "$MOCK_FLOCK_DIR"
! ss_refresh_lock_open
! ss_refresh_lock_open_wait 0
rmdir "$MOCK_FLOCK_DIR"
ss_refresh_lock_open
ss_spec_assert_exists "$MOCK_FLOCK_DIR"
ss_refresh_lock_close
ss_spec_assert_not_exists "$MOCK_FLOCK_DIR"
mkdir "$MOCK_FLOCK_DIR"
ss_should_stop() { return 0; }
set +e
ss_refresh_lock_open_wait 10
rc=$?
set -e
rmdir "$MOCK_FLOCK_DIR"
ss_spec_assert_eq "$rc" '130'
)
ss_case_service_lifecycle() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
INIT="$SS_SPEC_ROOT/files/etc/init.d/safeshield"
HARNESS="$TMP/init-lifecycle.sh"
sed -n '/^start_service() {/,/^reconcile_statistics() {/p' "$INIT" | sed '$d' >"$HARNESS"
# shellcheck disable=SC1090
. "$HARNESS"
export PKG_NAME='safeshield'
SS_BLOCKLIST_FILE="$TMP/dnsmasq.d/safeshield.blocklist"
export SS_INACTIVE_BLOCKLIST_FILE="$TMP/inactive.blocklist"
mkdir -p "${SS_BLOCKLIST_FILE%/*}"
CALLS="$TMP/calls"
: >"$CALLS"
record() { printf '%s\n' "$*" >>"$CALLS"; }
log_error() { :; }
log_warn() { :; }
ss_load_config() {
export ss_enabled="${MOCK_ENABLED:-1}"
export ss_statistics_enabled="${MOCK_STATISTICS:-1}"
return "${LOAD_RC:-0}"
}
ss_identity_ensure() {
record identity_ensure
return 0
}
ss_detect_device_model() { printf '%s\n' 'Test Router'; }
ss_detect_device_arch() { printf '%s\n' 'test-arch'; }
ss_mkdirs() {
record mkdirs
return 0
}
ss_statistics_request_rebaseline() {
record request_rebaseline
return 0
}
ss_statistics_configure_dnsmasq() {
record "statistics_configure $1"
printf '%s\n' "${STATISTICS_CHANGED:-0}"
}
ss_runtime_deactivate_blocklist() {
record deactivate_blocklist
return "${DEACTIVATE_RC:-0}"
}
ss_runtime_activate_blocklist() {
record activate_blocklist
return "${ACTIVATE_RC:-0}"
}
ss_status_set() { record "status_set $1 ${2:-}"; }
ss_status_mark_failure() { record "failure $1"; }
ss_require_supported_dnsmasq() {
record require_dnsmasq
return 0
}
ss_dnsmasq_check_error() { printf '%s\n' 'dnsmasq_version_unsupported'; }
ss_statistics_disable_if_dnsmasq_unpatched() {
record statistics_guard
return 0
}
ss_reconcile_installed_blocklists_for_dnsmasq() {
record reconcile_blocklists
return "${MIGRATION_RC:-0}"
}
ss_ensure_dnsmasq_confdir() {
record ensure_confdir
return 0
}
dnsmasq_restart() {
record dnsmasq_restart
return "${DNSMASQ_RC:-0}"
}
procd_open_instance() { record procd_open_instance; }
procd_set_param() { record "procd_set_param $*"; }
procd_close_instance() { record procd_close_instance; }
ss_statistics_add_procd_instance() { record add_statistics_instance; }
ss_statistics_add_upload_procd_instance() { record add_statistics_upload_instance; }
MOCK_ENABLED=0
MOCK_STATISTICS=1
STATISTICS_CHANGED=1
MIGRATION_RC=0
DNSMASQ_RC=0
: >"$CALLS"
start_service
ss_spec_assert_file_line "$CALLS" 'request_rebaseline'
ss_spec_assert_file_line "$CALLS" 'statistics_configure 0'
ss_spec_assert_file_line "$CALLS" 'deactivate_blocklist'
ss_spec_assert_file_line "$CALLS" 'dnsmasq_restart'
ss_spec_assert_file_line "$CALLS" 'status_set stage disabled'
! grep -Fx 'procd_open_instance' "$CALLS" >/dev/null
printf '%s\n' 'safeshield-block=/active.example/' >"$SS_BLOCKLIST_FILE"
: >"$CALLS"
start_service
ss_spec_assert_file_line "$CALLS" 'deactivate_blocklist'
! grep -Fx 'dnsmasq_restart' "$CALLS" >/dev/null
rm -f "$SS_BLOCKLIST_FILE"
MOCK_ENABLED=1
MOCK_STATISTICS=1
STATISTICS_CHANGED=0
MIGRATION_RC=0
: >"$CALLS"
start_service
ss_spec_assert_file_line "$CALLS" 'require_dnsmasq'
ss_spec_assert_file_line "$CALLS" 'statistics_guard'
ss_spec_assert_file_line "$CALLS" 'reconcile_blocklists'
ss_spec_assert_file_line "$CALLS" 'statistics_configure 1'
ss_spec_assert_file_line "$CALLS" 'activate_blocklist'
ss_spec_assert_file_line "$CALLS" 'procd_set_param command /usr/libexec/safeshield-refreshd'
ss_spec_assert_file_line "$CALLS" 'add_statistics_instance'
ss_spec_assert_file_line "$CALLS" 'add_statistics_upload_instance'
MIGRATION_RC=1
: >"$CALLS"
! start_service
ss_spec_assert_file_line "$CALLS" 'failure blocklist_directive_migration_failed'
! grep -Fx 'activate_blocklist' "$CALLS" >/dev/null
MIGRATION_RC=0
MOCK_ENABLED=1
STATISTICS_CHANGED=1
rm -f "$SS_BLOCKLIST_FILE"
: >"$CALLS"
stop_service
ss_spec_assert_file_line "$CALLS" 'statistics_configure 0'
ss_spec_assert_file_line "$CALLS" 'deactivate_blocklist'
ss_spec_assert_file_line "$CALLS" 'dnsmasq_restart'
ss_spec_assert_file_line "$CALLS" 'status_set stage stopped'
printf '%s\n' 'safeshield-block=/active.example/' >"$SS_BLOCKLIST_FILE"
: >"$CALLS"
stop_service
ss_spec_assert_file_line "$CALLS" 'deactivate_blocklist'
! grep -Fx 'dnsmasq_restart' "$CALLS" >/dev/null
rm -f "$SS_BLOCKLIST_FILE"
MOCK_ENABLED=0
STATISTICS_CHANGED=0
: >"$CALLS"
stop_service
ss_spec_assert_file_line "$CALLS" 'status_set status disabled'
ss_spec_assert_file_line "$CALLS" 'status_set last_result disabled'
)
reload_service() {
restart
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
# shellcheck shell=sh
ss_case_tooling() (
set -eu
[ -f "$SS_SPEC_ROOT/.pre-commit-config.yaml" ]
[ -f "$SS_SPEC_ROOT/.shellspec" ]
[ -f "$SS_SPEC_ROOT/spec/spec_helper.sh" ]
[ -f "$SS_SPEC_ROOT/spec/core_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/blocklist_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/runtime_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/statistics_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/ucode_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/tooling_spec.sh" ]
[ -f "$SS_SPEC_ROOT/scripts/lint.sh" ]
[ -f "$SS_SPEC_ROOT/.github/workflows/lint-shell.yml" ]
ss_spec_assert_file_contains "$SS_SPEC_ROOT/.pre-commit-config.yaml" 'entry: sh scripts/lint.sh'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/.pre-commit-config.yaml" 'always_run: true'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/.github/workflows/lint-shell.yml" 'sh scripts/lint.sh'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/.github/workflows/lint-shell.yml" 'SHELLSPEC_VERSION: 0.28.1'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/.github/workflows/lint-shell.yml" 'run: REQUIRE_UCODE=1 shellspec'
[ ! -e "$SS_SPEC_ROOT/tests/run.sh" ]
ss_spec_assert_file_contains "$SS_SPEC_ROOT/scripts/lint.sh" 'spec/*_spec.sh)'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/scripts/lint.sh" 'shfmt -d -ci'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/scripts/lint.sh" 'shellcheck -x -S warning'
ss_spec_assert_file_contains "$SS_SPEC_ROOT/scripts/lint.sh" 'sh -n "$file"'
[ ! -e "$SS_SPEC_ROOT/spec/legacy_tests_spec.sh" ]
)
ss_case_dead_code_contract() (
set -eu
UTILS="$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh"
STATUS="$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh"
LOG="$SS_SPEC_ROOT/files/usr/lib/safeshield/log.sh"
DNS="$SS_SPEC_ROOT/files/usr/lib/safeshield/dns.sh"
BLOCKLIST="$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
CONFIG="$SS_SPEC_ROOT/files/usr/lib/safeshield/config.sh"
INIT="$SS_SPEC_ROOT/files/etc/init.d/safeshield"
for symbol in \
str_contains \
str_contains_word \
str_first_word \
str_to_lower \
str_to_upper \
str_replace \
is_greater; do
! grep -Eq "^${symbol}\\(\\)[[:space:]]*\\{" "$UTILS"
done
for symbol in is_enabled is_active; do
! grep -Eq "^${symbol}\\(\\)[[:space:]]*\\{" "$STATUS"
done
for symbol in log_fail log_debug; do
! grep -Eq "^${symbol}\\(\\)[[:space:]]*\\{" "$LOG"
done
for symbol in ss_clear_active_blocklist dnsmasq_kill; do
! grep -Eq "^${symbol}\\(\\)[[:space:]]*\\{" "$DNS"
done
! grep -Eq '^ss_detect_primary_mac\\(\\)[[:space:]]*\\{' "$BLOCKLIST"
! grep -F 'ss_debug=' "$CONFIG"
! grep -F '/lib/functions/network.sh' "$INIT"
# Keep the replacement/runtime contracts that made the retired helpers obsolete.
ss_spec_assert_file_contains "$BLOCKLIST" 'ss_identity_ensure "$model" "$arch"'
ss_spec_assert_file_contains "$DNS" '/etc/init.d/dnsmasq restart'
ss_spec_assert_file_contains "$INIT" "procd_add_reload_interface_trigger 'wan'"
)
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# shellcheck shell=sh
ss_case_ucode() (
set -eu
MODULE_DIR="$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield"
ENTRY="$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield.uc"
STATUS_STORE="$SS_SPEC_ROOT/files/usr/lib/safeshield/status-store.uc"
UCODE_BIN="${UCODE:-ucode}"
if ! command -v "$UCODE_BIN" >/dev/null 2>&1; then
[ "${REQUIRE_UCODE:-0}" != '1' ]
return 0
fi
TMPDIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMPDIR"' EXIT HUP INT TERM
compile_ucode() {
file="$1"
output="$TMPDIR/$(basename -- "$file").ucb"
"$UCODE_BIN" -c -o "$output" "$file" >/dev/null
}
compile_ucode "$ENTRY"
compile_ucode "$STATUS_STORE"
for file in "$MODULE_DIR"/*.uc; do
compile_ucode "$file"
done
run_ucode_test() {
name="$1"
mock_dir="$SS_SPEC_ROOT/tests/ucode/mocks/$name"
test_file="$SS_SPEC_ROOT/tests/ucode/test_$name.uc"
test_tmp="$TMPDIR/$name"
test_modules="$TMPDIR/modules/$name"
mkdir -p "$test_tmp" "$test_modules"
cp "$MODULE_DIR/$name.uc" "$test_modules/$name.uc"
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
run_ucode_test "$name"
done
status_file="$TMPDIR/status-store.json"
printf '%s\n' 'not-json' >"$status_file"
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" dump)"
printf '%s\n' "$out" | grep -F '"data":{}' >/dev/null
printf '%s\n' "$out" | grep -F '"warnings":[]' >/dev/null
printf '%s\n' "$out" | grep -F '"errors":[]' >/dev/null
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" set stage running)"
printf '%s\n' "$out" >"$status_file"
printf '%s\n' "$out" | grep -F '"stage":"running"' >/dev/null
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" add_warning dnsmasq_warning)"
printf '%s\n' "$out" >"$status_file"
printf '%s\n' "$out" | grep -F '"code":"dnsmasq_warning"' >/dev/null
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" add_error artifact_download_failed)"
printf '%s\n' "$out" >"$status_file"
printf '%s\n' "$out" | grep -F '"code":"artifact_download_failed"' >/dev/null
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" clear_messages)"
printf '%s\n' "$out" | grep -F '"warnings":[]' >/dev/null
printf '%s\n' "$out" | grep -F '"errors":[]' >/dev/null
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" reset)"
printf '%s\n' "$out" | grep -F '"data":{}' >/dev/null
if "$UCODE_BIN" "$STATUS_STORE" "$status_file" set >/dev/null 2>&1; then
return 1
fi
if "$UCODE_BIN" "$STATUS_STORE" "$status_file" unknown >/dev/null 2>&1; then
return 1
fi
)
+17
View File
@@ -0,0 +1,17 @@
# shellcheck shell=sh
Describe 'SafeShield test and lint integration'
It 'uses native ShellSpec discovery and the shared lint entrypoint'
When call ss_case_tooling
The status should be success
The output should equal ''
The error should equal ''
End
It 'keeps retired shell helpers and unused runtime sources removed'
When call ss_case_dead_code_contract
The status should be success
The output should equal ''
The error should equal ''
End
End
+10
View File
@@ -0,0 +1,10 @@
# shellcheck shell=sh
Describe 'SafeShield ucode modules'
It 'compiles modules and runs the ucode regression suite when available'
When call ss_case_ucode
The status should be success
The output should equal ''
The error should equal ''
End
End
@@ -0,0 +1,78 @@
'use strict';
let values = {
enabled: '0',
debug: '0',
download_retry: '3',
statistics_enabled: '1',
license_key: 'abcd1234wxyz',
device_vendor: 'Junatum',
device_model: 'Test Router',
device_arch: 'aarch64',
device_memory_mb: '256'
};
const CONFIG_SPEC = {
debug: { kind: 'bool', def: false },
download_retry: { kind: 'int', def: 3, min: 1, max: 100 },
statistics_enabled: { kind: 'bool', def: true }
};
function to_bool(v, def) {
if (v == null) return def;
let s = sprintf('%s', v);
if (s == '1' || s == 'true' || s == 'yes' || s == 'on') return true;
if (s == '0' || s == 'false' || s == 'no' || s == 'off') return false;
return def;
}
function to_int(v, def) {
if (v == null || v == '') return def;
let n = int(v);
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 };
if (field) error.field = field;
return { ok: false, error: error };
}
let uci = {
set: function(pkg, section, name, value) {
values[name] = value;
return true;
},
commit: function() { return true; },
revert: function() { return true; }
};
function uci_commit_option(name, value) {
values[name] = sprintf('%s', value);
return true;
}
return {
uci: uci,
PKG_NAME: 'safeshield',
CONFIG_SCHEMA_NAME: 'safeshield.config',
CONFIG_SCHEMA_VERSION: 1,
CONFIG_SPEC: CONFIG_SPEC,
values: values,
reload_uci: function() { return true; },
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'; }
};
@@ -0,0 +1,22 @@
'use strict';
let state = {
last_action: '',
refresh_count: 0,
service_ok: true,
service_rc: 0,
refresh_accepted: true,
refresh_reason: ''
};
return {
state: state,
run_service_action: function(action) {
state.last_action = action;
return { ok: state.service_ok, rc: state.service_rc };
},
start_refresh_async: function() {
state.refresh_count++;
return { accepted: state.refresh_accepted, reason: state.refresh_reason };
}
};
+16
View File
@@ -0,0 +1,16 @@
'use strict';
function cursor() {
return {
load: function() { return true; },
get: function() { return null; },
set: function() { return true; },
delete: function() { return true; },
commit: function() { return true; },
revert: function() { return true; }
};
}
return {
cursor: cursor
};
@@ -0,0 +1,43 @@
'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;
}
};
@@ -0,0 +1,14 @@
'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,10 @@
'use strict';
let state = { accepted: true, reason: '' };
return {
state: state,
start_refresh_async: function() {
return { accepted: state.accepted, reason: state.reason };
}
};
@@ -0,0 +1,8 @@
'use strict';
let state = { value: 'test-status' };
return {
state: state,
build: function() { return { marker: state.value }; }
};
@@ -0,0 +1,32 @@
'use strict';
function trim(s) {
return replace(s, /^[\r\n\t ]+|[\r\n\t ]+$/g, '');
}
function to_bool(v, def) {
if (v == null) return def;
let s = sprintf('%s', v);
if (s == '1' || s == 'true' || s == 'yes' || s == 'on') return true;
if (s == '0' || s == 'false' || s == 'no' || s == 'off') return false;
return def;
}
function api_error(code, message, field) {
let error = { code: code, message: message };
if (field) error.field = field;
return { ok: false, error: error };
}
return {
LOCAL_ALLOWLIST_FILE: sprintf('%s/allowlist', TEST_TMP),
LOCAL_BLOCKLIST_FILE: sprintf('%s/blocklist', TEST_TMP),
RULES_SCHEMA_NAME: 'safeshield.rules',
RULES_SCHEMA_VERSION: 1,
RULES_DIR: TEST_TMP,
trim: trim,
reload_uci: function() { return true; },
cfg: function(name, def) { return name == 'apply_local_overrides' ? '1' : def; },
to_bool: to_bool,
api_error: api_error
};
@@ -0,0 +1,11 @@
'use strict';
let state = { apply_count: 0, accepted: true, reason: '' };
return {
state: state,
start_local_apply_async: function() {
state.apply_count++;
return { accepted: state.accepted, reason: state.reason };
}
};
@@ -0,0 +1,31 @@
'use strict';
let values = {
enabled: '1',
apply_local_overrides: '1'
};
let state = {
status: { data: { status: 'idle' } },
reloads: 0
};
function to_bool(v, def) {
if (v == null) return def;
let s = sprintf('%s', v);
if (s == '1' || s == 'true' || s == 'yes' || s == 'on') return true;
if (s == '0' || s == 'false' || s == 'no' || s == 'off') return false;
return def;
}
return {
PKG_NAME: 'safeshield',
STATUS_FILE: sprintf('%s/status.json', TEST_TMP),
SERVICE_INIT: '/bin/true',
values: values,
state: state,
read_json_file: function() { return state.status; },
to_bool: to_bool,
reload_uci: function() { state.reloads++; return true; },
cfg: function(name, def) { return values[name] == null ? def : values[name]; }
};
@@ -0,0 +1,27 @@
'use strict';
let state = {
services: {}
};
function connect() {
return {
call: function(object, method, args) {
if (object != 'service' || method != 'list' || !args || !args.name) {
return null;
}
let result = {};
let service = state.services[args.name];
if (service != null) {
result[args.name] = service;
}
return result;
}
};
}
return {
state: state,
connect: connect
};
@@ -0,0 +1,80 @@
'use strict';
let config = {
statistics_enabled: '1',
statistics_retention_hours: '24',
statistics_snapshot_interval_s: '30'
};
let data = {
schema: { name: 'collector' },
generation_id: 'generation-test',
snapshot_seq: '42',
volatile: '0',
storage: 'tmpfs+flash',
persistent: '1',
persistence_enabled: '1',
persistence_healthy: '1',
persistence_mode: 'journal',
persistent_error_count: '2',
persistent_last_error_at: '190',
persistent_updated_at: '195',
persistent_checkpoint_interval_s: '3600',
persistent_compacted_at: '180',
persistent_compact_interval_s: '604800',
started_at: '100',
session_started_at: '150',
updated_at: '200',
totals: { queries: '120', blocked: '12' },
source: {
backend: 'dnsmasq_ubus',
available: '1',
instance_id: 'epoch-test',
transport_scope: 'udp',
client_capacity: '128',
tracked_clients: '2',
untracked_queries: '3',
untracked_blocked: '2',
poll_error_count: '4',
last_error_at: '199'
},
hourly: [
{ bucket_start: '10', queries: '20', blocked: '2' },
'invalid',
{ bucket_start: 20, queries: 30, blocked: 3 }
],
device_limit: '128',
devices_truncated: '1',
devices: [
{ id: 'aa:bb', mac: 'AA:BB', ip: '192.0.2.10', hostname: 'phone', identified: '1', queries: '50', blocked: '5' },
{ id: '', ip: '192.0.2.11', queries: 10 },
'invalid'
]
};
function to_bool(v, def) {
if (v == null) return def;
let s = sprintf('%s', v);
if (s == '1' || s == 'true' || s == 'yes' || s == 'on') return true;
if (s == '0' || s == 'false' || s == 'no' || s == 'off') return false;
return def;
}
function to_int(v, def) {
if (v == null || v == '') return def;
let n = int(v);
return type(n) == 'int' ? n : def;
}
return {
PKG_NAME: 'safeshield',
STATISTICS_SCHEMA_NAME: 'safeshield.statistics',
STATISTICS_SCHEMA_VERSION: 3,
STATISTICS_FILE: '/tmp/statistics.json',
data: data,
reload_uci: function() { return true; },
cfg: function(name, def) { return config[name] == null ? def : config[name]; },
read_json_file: function() { return data; },
to_bool: to_bool,
to_int: to_int
};
@@ -0,0 +1,7 @@
'use strict';
return {
service_instance_running: function(name, instance) {
return name == 'safeshield' && instance == 'statistics';
}
};
@@ -0,0 +1,98 @@
'use strict';
let config = {
enabled: '1',
refresh_on_boot: '1',
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',
device_arch: 'aarch64',
device_memory_mb: '256'
};
let identity = {
physical_fingerprint: 'fingerprint',
fingerprint_version: '1',
identity_provider: 'factory-mac',
identity_source: 'eth0',
identity_strength: 'strong',
identity_profile: 'test-profile',
device_code: 'xiaomi-ax3000t',
device_code_source: 'smartsafehub_firmware',
installation_id: 'install-id'
};
let state = {
data: {
status: 'ready',
valid_line_count: '42000',
artifact_tier: 'pro',
artifact_version: '20260830T000000Z',
artifact_unique_domains: '41000',
artifact_source_count: '2',
artifact_block_source_count: '1',
artifact_allow_source_count: '1',
health_api_resolve: '1',
health_artifact_download: '1',
health_dns_runtime: '1',
blocklist_installed: '1',
blocklist_file_size_kb: '2048',
license_plan: 'pro',
license_status: 'active'
},
warnings: [],
errors: []
};
function to_bool(v, def) {
if (v == null) return def;
let s = sprintf('%s', v);
if (s == '1' || s == 'true' || s == 'yes' || s == 'on') return true;
if (s == '0' || s == 'false' || s == 'no' || s == 'off') return false;
return def;
}
function to_optional_bool(v) {
if (v == null || v == '') return null;
return to_bool(v, null);
}
function to_int(v, def) {
if (v == null || v == '') return def;
let n = int(v);
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',
PKG_VERSION: '0.3.16-r4',
STATUS_FILE: '/tmp/status.json',
BLOCKLIST_FILE: '/tmp/blocklist',
LOCAL_ALLOWLIST_FILE: '/etc/safeshield/allowlist',
LOCAL_BLOCKLIST_FILE: '/etc/safeshield/blocklist',
SCHEMA_NAME: 'safeshield.status',
SCHEMA_VERSION: 1,
state: state,
config: config,
reload_uci: function() { return true; },
cfg: function(name, def) { return config[name] == null ? def : config[name]; },
identity_cfg: function(name, def) { return identity[name] == null ? def : identity[name]; },
read_json_file: function() { return state; },
file_exists: function() { return false; },
file_size_kb: function() { return 0; },
to_bool: to_bool,
to_optional_bool: to_optional_bool,
to_int: to_int,
mask_secret: mask_secret
};
@@ -0,0 +1,9 @@
'use strict';
let state = { service_running: true, dnsmasq_running: true };
return {
state: state,
service_running: function() { return state.service_running; },
dnsmasq_running: function() { return state.dnsmasq_running; }
};
+83
View File
@@ -0,0 +1,83 @@
'use strict';
let core = require('core');
let runtime = require('runtime');
let config = require('config');
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' } });
assert(invalid_values.ok == false && invalid_values.error.code == 'invalid_type', 'config_update requires an object');
let invalid_type = config.update({ args: { values: { debug: 'true' } } });
assert(invalid_type.ok == false && invalid_type.error.code == 'invalid_type', 'boolean options reject strings');
let out_of_range = config.update({ args: { values: { download_retry: 101 } } });
assert(out_of_range.ok == false && out_of_range.error.code == 'out_of_range', 'integer options enforce ranges');
let unknown = config.update({ args: { values: { unknown_option: true } } });
assert(unknown.ok == false && unknown.error.code == 'unknown_option', 'unknown options are rejected');
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');
runtime.state.last_action = '';
let statistics = config.update({ args: { values: { statistics_enabled: false } } });
assert(statistics.ok == true, 'statistics update succeeds');
assert(statistics.reconciled == true && statistics.restarted == false, 'statistics-only update reconciles without restart');
assert(runtime.state.last_action == 'reconcile_statistics', 'statistics update requests reconciliation');
assert(core.values.statistics_enabled == '0', 'statistics update commits UCI value');
runtime.state.last_action = '';
runtime.state.refresh_count = 0;
let debug = config.update({ args: { values: { debug: true } } });
assert(debug.ok == true && debug.restarted == true, 'regular config update restarts service');
assert(runtime.state.last_action == 'restart', 'regular config update requests restart');
assert(runtime.state.refresh_count == 1, 'regular config update requests refresh');
assert(core.values.debug == '1', 'regular config update commits UCI value');
runtime.state.last_action = '';
runtime.state.refresh_count = 0;
let unchanged = config.update({ args: { values: { debug: true } } });
assert(unchanged.ok == true && length(unchanged.changed) == 0 && unchanged.restarted == false, 'unchanged config is a no-op');
assert(runtime.state.last_action == '' && runtime.state.refresh_count == 0, 'unchanged config does not touch runtime');
runtime.state.service_ok = false;
runtime.state.service_rc = 9;
let statistics_failure = config.update({ args: { values: { statistics_enabled: true } } });
assert(statistics_failure.ok == false && statistics_failure.committed == true, 'statistics reconcile failure reports committed configuration');
assert(statistics_failure.error.code == 'statistics_reconcile_failed' && statistics_failure.service_rc == 9, 'statistics reconcile failure preserves runtime error');
runtime.state.service_rc = 11;
let restart_failure = config.update({ args: { values: { download_retry: 4 } } });
assert(restart_failure.ok == false && restart_failure.committed == true, 'restart failure reports committed configuration');
assert(restart_failure.error.code == 'service_restart_failed' && restart_failure.service_rc == 11, 'restart failure preserves runtime error');
runtime.state.service_ok = true;
runtime.state.service_rc = 0;
let missing_enabled = config.set_enabled({ args: {} });
assert(missing_enabled.ok == false && missing_enabled.error.code == 'missing_argument', 'set_enabled requires enabled argument');
let invalid_enabled = config.set_enabled({ args: { enabled: 1 } });
assert(invalid_enabled.ok == false && invalid_enabled.error.code == 'invalid_type', 'set_enabled requires boolean');
let enabled = config.set_enabled({ args: { enabled: true } });
assert(enabled.ok == true && enabled.changed == true, 'set_enabled updates changed state');
assert(core.values.enabled == '1', 'set_enabled commits enabled state');
runtime.state.service_ok = false;
runtime.state.service_rc = 13;
let disable_failure = config.set_enabled({ args: { enabled: false } });
assert(disable_failure.ok == false && disable_failure.committed == true, 'set_enabled reports a committed change when runtime reconciliation fails');
assert(disable_failure.error.code == 'service_restart_failed' && disable_failure.service_rc == 13, 'set_enabled failure preserves runtime error');
print('ucode config tests: ok\n');
+36
View File
@@ -0,0 +1,36 @@
'use strict';
let core = require('core');
assert(core.trim(' example.com\n') == 'example.com', 'trim removes surrounding whitespace');
assert(core.trim('\t value \r\n') == 'value', 'trim removes tabs and line breaks');
assert(core.trim('value ') == 'value', 'trim removes trailing whitespace');
assert(core.trim(' value') == 'value', 'trim removes leading whitespace');
assert(core.to_bool('1', false) == true, 'to_bool accepts 1');
assert(core.to_bool('yes', false) == true, 'to_bool accepts yes');
assert(core.to_bool('off', true) == false, 'to_bool accepts off');
assert(core.to_bool('invalid', true) == true, 'to_bool falls back to default');
assert(core.to_bool(null, false) == false, 'to_bool handles null');
assert(core.to_optional_bool('true') == true, 'to_optional_bool accepts true');
assert(core.to_optional_bool('0') == false, 'to_optional_bool accepts 0');
assert(core.to_optional_bool('') == null, 'to_optional_bool treats empty as null');
assert(core.to_optional_bool('invalid') == null, 'to_optional_bool rejects invalid values');
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');
assert(error.error.code == 'invalid_value', 'api_error preserves error code');
assert(error.error.field == 'field_name', 'api_error preserves error field');
assert(core.bool_uci(true) == '1', 'bool_uci serializes true');
assert(core.bool_uci(false) == '0', 'bool_uci serializes false');
print('ucode core tests: ok\n');
+44
View File
@@ -0,0 +1,44 @@
'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');
+21
View File
@@ -0,0 +1,21 @@
'use strict';
let runtime = require('runtime');
let refresh = require('refresh');
let accepted = refresh.call();
assert(accepted.ok == true && accepted.accepted == true, 'accepted refresh succeeds');
assert(accepted.status.marker == 'test-status', 'refresh returns current status');
runtime.state.accepted = false;
runtime.state.reason = 'already_running';
let running = refresh.call();
assert(running.ok == true && running.accepted == false, 'already-running refresh is idempotent');
assert(running.reason == 'already_running', 'already-running reason is preserved');
runtime.state.reason = 'disabled';
let disabled = refresh.call();
assert(disabled.ok == false && disabled.accepted == false, 'disabled refresh is rejected');
assert(disabled.reason == 'disabled', 'disabled reason is preserved');
print('ucode refresh tests: ok\n');
+43
View File
@@ -0,0 +1,43 @@
'use strict';
let fs = require('fs');
let runtime = require('runtime');
let rules = require('rules');
let invalid_action = rules.add({ args: { action: 'deny', domain: 'example.com' } });
assert(invalid_action.ok == false && invalid_action.error.code == 'invalid_action', 'rules reject invalid actions');
let invalid_domain = rules.add({ args: { action: 'block', domain: 'bad..example.com' } });
assert(invalid_domain.ok == false && invalid_domain.error.code == 'invalid_domain', 'rules reject invalid domains');
let allow = rules.add({ args: { action: 'allow', domain: ' Example.COM ', refresh: false } });
assert(allow.ok == true && allow.added == true, 'allow rule is added');
assert(allow.domain == 'example.com', 'rule domain is normalized to lowercase');
assert(allow.refresh.reason == 'not_requested', 'refresh=false skips local apply');
let duplicate = rules.add({ args: { action: 'allow', domain: 'example.com' } });
assert(duplicate.ok == true && duplicate.added == false, 'duplicate rule is idempotent');
assert(duplicate.refresh.reason == 'unchanged', 'duplicate rule does not refresh');
runtime.state.apply_count = 0;
let block = rules.add({ args: { action: 'block', domain: '0.0.0.0 Ads.Example.COM' } });
assert(block.ok == true && block.domain == 'ads.example.com', 'hosts-format rule is normalized');
assert(runtime.state.apply_count == 1, 'changed rule requests local apply');
let listed = rules.list({ args: {} });
assert(listed.ok == true, 'rules_list succeeds');
assert(listed.counts.allow == 1 && listed.counts.block == 1, 'rules_list reports counts');
assert(listed.allow[0] == 'example.com', 'rules_list returns allow entry');
assert(listed.block[0] == 'ads.example.com', 'rules_list returns block entry');
let allow_only = rules.list({ args: { action: 'allow' } });
assert(length(allow_only.block) == 0 && length(allow_only.allow) == 1, 'rules_list filters by action');
let deleted = rules.delete({ args: { action: 'allow', domain: 'EXAMPLE.COM', refresh: false } });
assert(deleted.ok == true && deleted.deleted == true, 'rule_delete removes normalized rule');
assert(deleted.rules.counts.allow == 0, 'rule_delete returns updated rule count');
let content = fs.readfile(sprintf('%s/blocklist', TEST_TMP));
assert(content == 'ads.example.com\n', 'block rule file stores normalized domain');
print('ucode rules tests: ok\n');
+74
View File
@@ -0,0 +1,74 @@
'use strict';
let core = require('core');
let ubus = require('ubus');
let runtime = require('runtime');
ubus.state.services.safeshield = {
instances: {
refreshd: { running: true },
stopped: { running: false }
}
};
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');
assert(runtime.service_running('missing') == false, 'service_running rejects missing services');
assert(runtime.service_instance_running('missing', 'refreshd') == false, 'service_instance_running rejects missing services');
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');
let action = runtime.run_service_action('status', 1000);
assert(action.ok == true && action.rc == 0, 'run_service_action reports successful service commands');
core.state.status = { data: { status: 'running' } };
assert(runtime.refresh_running() == true, 'refresh_running recognizes running status');
core.state.status = { data: { status: 'ready' } };
assert(runtime.refresh_running() == false, 'refresh_running rejects non-running status');
core.values.enabled = '0';
let refresh_disabled = runtime.start_refresh_async();
assert(refresh_disabled.accepted == false && refresh_disabled.reason == 'disabled', 'start_refresh_async rejects disabled SafeShield');
core.values.enabled = '1';
ubus.state.services.safeshield = null;
let refresh_stopped = runtime.start_refresh_async();
assert(refresh_stopped.accepted == false && refresh_stopped.reason == 'service_stopped', 'start_refresh_async rejects a stopped service');
ubus.state.services.safeshield = { instances: { refreshd: { running: true } } };
core.state.status = { data: { status: 'running' } };
let refresh_running = runtime.start_refresh_async();
assert(refresh_running.accepted == false && refresh_running.reason == 'already_running', 'start_refresh_async is idempotent during a refresh');
core.state.status = { data: { status: 'ready' } };
let refresh_spawned = runtime.start_refresh_async();
assert(refresh_spawned.accepted == true && refresh_spawned.reason == '' && refresh_spawned.rc == 0, 'start_refresh_async launches the detached refresh worker');
core.values.enabled = '0';
let local_disabled = runtime.start_local_apply_async();
assert(local_disabled.accepted == false && local_disabled.reason == 'disabled', 'start_local_apply_async rejects disabled SafeShield');
core.values.enabled = '1';
core.values.apply_local_overrides = '0';
let local_overrides_disabled = runtime.start_local_apply_async();
assert(local_overrides_disabled.accepted == false && local_overrides_disabled.reason == 'local_overrides_disabled', 'start_local_apply_async requires local overrides');
core.values.apply_local_overrides = '1';
ubus.state.services.safeshield = null;
let local_stopped = runtime.start_local_apply_async();
assert(local_stopped.accepted == false && local_stopped.reason == 'service_stopped', 'start_local_apply_async rejects a stopped service');
ubus.state.services.safeshield = { instances: { refreshd: { running: true } } };
let local_spawned = runtime.start_local_apply_async();
assert(local_spawned.accepted == true && local_spawned.reason == '' && local_spawned.rc == 0, 'start_local_apply_async launches the detached local-rule worker');
assert(core.state.reloads >= 8, 'async entrypoints reload UCI before checking runtime state');
print('ucode runtime tests: ok\n');
reload_service() {
restart
}
+29
View File
@@ -0,0 +1,29 @@
'use strict';
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_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');
assert(result.snapshot_seq == 42, 'statistics snapshot sequence is preserved');
assert(result.started_at == 100 && result.session_started_at == 150 && result.updated_at == 200, 'statistics timestamps are normalized');
assert(result.volatile == false && result.storage == 'tmpfs+flash' && result.persistent == true, 'statistics storage metadata is normalized');
assert(result.persistence_enabled == true && result.persistence_healthy == true && result.persistence_mode == 'journal', 'statistics persistence state is normalized');
assert(result.persistent_error_count == 2 && result.persistent_last_error_at == 190, 'statistics persistence errors are normalized');
assert(result.persistent_updated_at == 195 && result.persistent_checkpoint_interval_s == 3600, 'statistics persistence checkpoint metadata is normalized');
assert(result.totals.queries == 120 && result.totals.blocked == 12, 'statistics totals are normalized');
assert(result.source.backend == 'dnsmasq_ubus' && result.source.available == true, 'statistics source backend is normalized');
assert(result.source.instance_id == 'epoch-test' && result.source.transport_scope == 'udp', 'statistics source epoch and transport scope are preserved');
assert(result.source.client_capacity == 128 && result.source.tracked_clients == 2 && result.source.untracked_queries == 3 && result.source.untracked_blocked == 2, 'statistics source client metadata is normalized');
assert(result.source.poll_error_count == 4 && result.source.last_error_at == 199, 'statistics source errors are normalized');
assert(length(result.hourly) == 2, 'invalid hourly entries are discarded');
assert(result.hourly[0].queries == 20 && result.hourly[1].blocked == 3, 'hourly values are normalized');
assert(result.device_limit == 128 && result.devices_truncated == true, 'device metadata is normalized');
assert(length(result.devices) == 1, 'invalid devices and empty ids are discarded');
assert(result.devices[0].identified == true, 'device identified flag is normalized');
assert(result.devices[0].queries == 50 && result.devices[0].blocked == 5, 'device counters are normalized');
print('ucode statistics tests: ok\n');
+53
View File
@@ -0,0 +1,53 @@
'use strict';
let core = require('core');
let status = require('status');
let ready = status.build();
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');
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.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');
core.state.data.status = 'error';
core.state.errors = [ { code: 'artifact_download_failed' } ];
let error = status.build();
assert(error.summary.label == 'Error' && error.summary.severity == 'error', 'error summary is generated');
assert(error.summary.message == 'artifact_download_failed', 'error summary uses first error code');
assert(error.health.overall == 'error', 'error status reports overall error');
core.state.data.status = 'ready';
core.state.errors = [];
core.state.warnings = [ { code: 'dnsmasq_warning' } ];
let warning = status.build();
assert(warning.summary.severity == 'warning', 'warnings elevate informational summary severity');
assert(warning.health.overall == 'warning', 'warnings report overall warning health');
core.config.enabled = '0';
core.state.data = {};
core.state.warnings = [];
let disabled = status.build();
assert(disabled.status == 'disabled', 'disabled config falls back to disabled status');
assert(disabled.summary.label == 'Disabled' && disabled.summary.severity == 'warning', 'disabled summary is generated');
assert(disabled.sources.items[0].last_result == 'disabled', 'disabled API source is reported disabled');
core.config.enabled = '1';
core.state.data = {
status: 'error',
health_api_resolve: '0',
last_error_code: 'safeshield_upgrade_required'
};
core.state.errors = [ { code: 'safeshield_upgrade_required' } ];
core.state.warnings = [];
let upgrade_required = status.build();
assert(upgrade_required.sources.items[0].last_result == 'safeshield_upgrade_required', 'upgrade-required source result is preserved');
assert(upgrade_required.summary.message == 'safeshield_upgrade_required', 'upgrade-required summary uses the specific error code');
print('ucode status tests: ok\n');