mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 03:25:13 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,19 @@
|
||||
# MacOS related
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Vim
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# IDE user config
|
||||
.vscode/
|
||||
.idea/
|
||||
.vs/
|
||||
|
||||
# etc
|
||||
*.orig
|
||||
*.rej
|
||||
/*.patch
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
external-sources=true
|
||||
severity=warning
|
||||
@@ -0,0 +1,4 @@
|
||||
--require spec_helper
|
||||
--shell sh
|
||||
--fail-no-examples
|
||||
--format documentation
|
||||
@@ -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.
|
||||
@@ -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>.
|
||||
@@ -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))
|
||||
@@ -0,0 +1,671 @@
|
||||
# SafeShield
|
||||
|
||||
[](https://github.com/Junatum/safeshield/actions/workflows/lint-shell.yml)
|
||||

|
||||

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