🎉 Sync 2026-11-02 12:48:14

This commit is contained in:
github-actions[bot] committed 2026-11-02 12:48:14 +08:00
1 parent 42d5cbfa43
commit db798bdf5e
53 files changed
+1895 -568

No files matched your search

+2 -2
View File
@@ -8,12 +8,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=apfree-wifidog PKG_NAME:=apfree-wifidog
PKG_RELEASE:=9 PKG_RELEASE:=10
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/liudf0716/apfree-wifidog.git PKG_SOURCE_URL:=https://github.com/liudf0716/apfree-wifidog.git
PKG_SOURCE_DATE:=2026-10-25 PKG_SOURCE_DATE:=2026-10-25
PKG_SOURCE_VERSION:=4075182ffedaf19b36926dc2f068fe413901aba3 PKG_SOURCE_VERSION:=9997baf26c737102940dd3a44e9d95c84dd57dab
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_MAINTAINER:=Dengfeng Liu <liudf0716@gmail.com> PKG_MAINTAINER:=Dengfeng Liu <liudf0716@gmail.com>
+13 -5
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=dockerd PKG_NAME:=dockerd
PKG_VERSION:=29.6.1 PKG_VERSION:=29.6.1
PKG_RELEASE:=10 PKG_RELEASE:=11
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE PKG_LICENSE_FILES:=LICENSE
PKG_CPE_ID:=cpe:/a:mobyproject:moby PKG_CPE_ID:=cpe:/a:mobyproject:moby
@@ -138,10 +138,18 @@ define Package/dockerd/install
$(INSTALL_DIR) $(1)/etc/config $(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/dockerd $(1)/etc/config/dockerd $(INSTALL_CONF) ./files/etc/config/dockerd $(1)/etc/config/dockerd
# Must be after systcl 11-br-netfilter.conf from kmod-br-netfilter $(INSTALL_DIR) $(1)/etc/hotplug.d/net
$(INSTALL_DIR) $(1)/etc/sysctl.d $(INSTALL_DATA) ./files/etc/hotplug.d/net/br-netfilter \
$(INSTALL_DATA) ./files/etc/sysctl.d/sysctl-br-netfilter-ip.conf \ $(1)/etc/hotplug.d/net/10-docker-br-netfilter
$(1)/etc/sysctl.d/12-br-netfilter-ip.conf
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_DATA) ./files/etc/uci-defaults/16_docker-netifd-migrate2 \
$(1)/etc/uci-defaults/16_docker-netifd-migrate2
$(INSTALL_DATA) ./files/etc/uci-defaults/17_docker-fw4 \
$(1)/etc/uci-defaults/17_docker-fw4
$(INSTALL_DIR) $(1)/lib/fstab.d
$(INSTALL_DATA) ./files/lib/fstab.d/dockerd.sh $(1)/lib/fstab.d/dockerd.sh
endef endef
define Package/dockerd/postinst define Package/dockerd/postinst
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.29 PKG_VERSION:=26.10.29
PKG_RELEASE:=362 PKG_RELEASE:=363
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -149,6 +149,13 @@ local com = self.com
function doUpdate(btn, app) { function doUpdate(btn, app) {
if (luciUpdating || (app === appname && componentUpdates > 0)) return; if (luciUpdating || (app === appname && componentUpdates > 0)) return;
var appInfo = appInfoList[app];
if (!appInfo || !appInfo.data || !appInfo.data.browser_download_url ||
(app === appname && (!appInfo.i18n || !appInfo.i18n.browser_download_url))) {
onRequestError(btn, updateFailedText);
return;
}
if (app === appname) luciUpdating = true; if (app === appname) luciUpdating = true;
else componentUpdates++; else componentUpdates++;
setButtonDisabled(btn, true); setButtonDisabled(btn, true);
@@ -157,7 +164,6 @@ local com = self.com
addPageNotice(); addPageNotice();
var appUpdateUrl = '<%=api.url("update_")%>' + app; var appUpdateUrl = '<%=api.url("update_")%>' + app;
var appInfo = appInfoList[app];
var downloadId = Date.now().toString(36) + Math.random().toString(36).substring(2, 8); var downloadId = Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
var totalSize = appInfo && appInfo.data ? Number(appInfo.data.size) : 0; var totalSize = appInfo && appInfo.data ? Number(appInfo.data.size) : 0;
if (app === appname) totalSize += Number(appInfo.i18n.size) || 0; if (app === appname) totalSize += Number(appInfo.i18n.size) || 0;
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2 PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.10.29 PKG_VERSION:=26.10.29
PKG_RELEASE:=163 PKG_RELEASE:=164
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -149,6 +149,20 @@ local com = self.com
function doUpdate(btn, app) { function doUpdate(btn, app) {
if (luciUpdating || (app === appname && componentUpdates > 0)) return; if (luciUpdating || (app === appname && componentUpdates > 0)) return;
var appInfo = appInfoList[app];
if (!appInfo || !appInfo.data) {
onRequestError(btn, updateFailedText);
return;
}
var downloadList = [appInfo.data];
if (app === appname && Array.isArray(appInfo.i18n)) downloadList = downloadList.concat(appInfo.i18n);
for (var i = 0; i < downloadList.length; i++) {
if (!downloadList[i] || !downloadList[i].browser_download_url) {
onRequestError(btn, updateFailedText);
return;
}
}
if (app === appname) luciUpdating = true; if (app === appname) luciUpdating = true;
else componentUpdates++; else componentUpdates++;
setButtonDisabled(btn, true); setButtonDisabled(btn, true);
@@ -157,10 +171,7 @@ local com = self.com
addPageNotice(); addPageNotice();
var appUpdateUrl = '<%=api.url("update_")%>' + app; var appUpdateUrl = '<%=api.url("update_")%>' + app;
var appInfo = appInfoList[app];
var downloadId = Date.now().toString(36) + Math.random().toString(36).substring(2, 8); var downloadId = Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
var downloadList = appInfo && appInfo.data ? [appInfo.data] : [];
if (app === appname && Array.isArray(appInfo.i18n)) downloadList = downloadList.concat(appInfo.i18n);
var totalSize = 0; var totalSize = 0;
for (var i = 0; i < downloadList.length; i++) totalSize += Number(downloadList[i].size) || 0; for (var i = 0; i < downloadList.length; i++) totalSize += Number(downloadList[i].size) || 0;
if (!isFinite(totalSize) || totalSize <= 0) { if (!isFinite(totalSize) || totalSize <= 0) {
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-trafficctl PKG_NAME:=luci-app-trafficctl
PKG_VERSION:=1.25.2 PKG_VERSION:=1.25.4
PKG_RELEASE:=18 PKG_RELEASE:=19
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com> PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com>
@@ -41,9 +41,23 @@ fi
# Restore blocks and ratelimits (if persist enabled) # Restore blocks and ratelimits (if persist enabled)
if [ -f "$RULES_FILE" ] && tctl_persist_enabled; then if [ -f "$RULES_FILE" ] && tctl_persist_enabled; then
jsonfilter -i "$RULES_FILE" -e '@[*]' 2>/dev/null | while read -r _; do true; done # How many records there are comes from the file, not from a constant.
# `seq 0 99` stopped at the hundredth, so every rule past it was silently
for idx in $(seq 0 99); do # not restored: present in rules.json, shown by the UI as persisted, and
# absent from the kernel after a reboot (#108). Raising 99 to a bigger
# number would only move the cliff. The absent-record sentinel below is
# what ends the loop, exactly as it did before.
#
# (The line that used to sit here piped '@[*]' into a loop whose body was
# `true` — an abandoned attempt at this same iteration that did nothing.)
# Advanced at the TOP, so there is exactly one increment and `continue`
# below cannot skip it. With the increment at the bottom, a record that
# has a type but no ip — a truncated or hand-edited file — would spin the
# loop forever on the same index, inside a boot hook. That is a worse
# failure than the one being fixed here.
idx=-1
while :; do
idx=$((idx + 1))
type=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].type" 2>/dev/null) type=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].type" 2>/dev/null)
[ -z "$type" ] && break [ -z "$type" ] && break
ip=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].ip" 2>/dev/null) ip=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].ip" 2>/dev/null)
@@ -94,6 +94,10 @@ STAMP="/tmp/trafficctl_history.stamp"
LOCKD="/tmp/trafficctl_history.lock.d" LOCKD="/tmp/trafficctl_history.lock.d"
TOTALS_STATE="/tmp/trafficctl_totals.state" TOTALS_STATE="/tmp/trafficctl_totals.state"
IFCURSOR="/tmp/trafficctl_history.ifcursor" IFCURSOR="/tmp/trafficctl_history.ifcursor"
# Per-period, and suffixed with the period id so a period roll starts a fresh
# one instead of needing to be cleared. tmpfs: it is rebuilt from the durable
# file whenever it is missing, which is every boot.
IFACC="/tmp/trafficctl_history.ifacc"
# Row layout, tab-separated: # Row layout, tab-separated:
# key mac ip name rx tx tcp udp first_seen last_seen degraded # key mac ip name rx tx tcp udp first_seen last_seen degraded
@@ -628,15 +632,27 @@ hist_if_accuracy() {
# time the link goes down, which on the multi-WAN box this feature is for is # time the link goes down, which on the multi-WAN box this feature is for is
# routine — so a flap costs one sampling interval instead of corrupting the # routine — so a flap costs one sampling interval instead of corrupting the
# month. # month.
hist_if_flush() { # Fold the growth since the last tick into the tmpfs accumulator and advance
local dir pid file tmp now sample rc=0 # the cursor. Called on every sample tick, because a cursor that only moved at
# flush time lost up to a whole flush interval of a link's traffic whenever its
# counter restarted — an hour by default, on exactly the multi-WAN box with a
# flapping link this series exists for (#111). The accumulator is in tmpfs, so
# touching it every 60s costs no flash.
#
# The durable file is written by hist_if_flush alone. The accumulator is seeded
# from it when absent (every boot, and on a period roll), which is what makes
# the eventual copy-over safe rather than double-counting.
hist_if_accumulate() {
local dir pid file tmp now sample acc rc=0
hist_ifaces_enabled || return 0 hist_ifaces_enabled || return 0
dir=$(hist_dir) dir=$(hist_dir)
pid=$(hist_period_id) pid=$(hist_period_id)
file=$(hist_iffile "$pid") file=$(hist_iffile "$pid")
acc="$IFACC.$pid"
now=$(date +%s) now=$(date +%s)
sample="/tmp/.trafficctl_history.ifsample.$$" sample="/tmp/.trafficctl_history.ifsample.$$"
tmp="$dir/.history-if.$pid.$$" tmp="$acc.$$"
[ -f "$acc" ] && file="$acc"
# shellcheck disable=SC2064 # shellcheck disable=SC2064
trap "rm -f '$sample' '$tmp'" EXIT INT TERM trap "rm -f '$sample' '$tmp'" EXIT INT TERM
@@ -694,12 +710,24 @@ hist_if_flush() {
lrx = num(line, "rx_bytes"); if (lrx < 0) continue lrx = num(line, "rx_bytes"); if (lrx < 0) continue
ltx = num(line, "tx_bytes"); if (ltx < 0) continue ltx = num(line, "tx_bytes"); if (ltx < 0) continue
# A live counter BELOW the cursor has restarted: the link went
# down and came back, or the driver reset it. The bytes carried
# since then have never been counted by anything, because a
# /proc/net/dev counter is monotonic for the lifetime of the
# interface, so they are the delta.
#
# Adding nothing here is right for the DEVICE series, where the
# live value is a conntrack sum that collapses when flows expire
# and those bytes were already accumulated while the flows lived.
# That premise does not transfer to an interface counter, and the
# zero came across with the code (#111). It also made this branch
# disagree with the unseen-interface case, which always took the
# full counter: the same situation, a counter starting from zero,
# handled two ways depending on whether the cursor happened to
# remember the interface.
drx = lrx; dtx = ltx drx = lrx; dtx = ltx
if (seen[d]) { if (lrx >= crx[d]) drx = lrx - crx[d]
drx = 0; dtx = 0 if (ltx >= ctx[d]) dtx = ltx - ctx[d]
if (lrx >= crx[d]) drx = lrx - crx[d]
if (ltx >= ctx[d]) dtx = ltx - ctx[d]
}
if (!(d in have)) { if (!(d in have)) {
order[++norder] = d order[++norder] = d
have[d] = 1 have[d] = 1
@@ -736,11 +764,32 @@ hist_if_flush() {
}' </dev/null || rc=1 }' </dev/null || rc=1
[ "$rc" = 0 ] && [ -f "$tmp" ] || return 1 [ "$rc" = 0 ] && [ -f "$tmp" ] || return 1
hist_guard mv "$tmp" "$file" 2>/dev/null || return 1 mv "$tmp" "$acc" 2>/dev/null || return 1
# Only after the accumulator is in place, for the same reason the device
# cursor waits for its period file: advancing first and then failing would
# discard the growth for good, where this way it is folded in next tick.
mv "$IFCURSOR.new" "$IFCURSOR" 2>/dev/null || true mv "$IFCURSOR.new" "$IFCURSOR" 2>/dev/null || true
return 0 return 0
} }
# Publish the accumulator to the durable per-period file. Picks up the final
# interval first, so no traffic is stranded in tmpfs at flush time.
hist_if_flush() {
local pid acc file tmp
hist_ifaces_enabled || return 0
hist_if_accumulate || return 1
pid=$(hist_period_id)
acc="$IFACC.$pid"
[ -f "$acc" ] || return 0
file=$(hist_iffile "$pid")
tmp="$(hist_dir)/.history-if.$pid.$$"
# shellcheck disable=SC2064
trap "rm -f '$tmp'" EXIT INT TERM
hist_guard cp "$acc" "$tmp" 2>/dev/null || return 1
hist_guard mv "$tmp" "$file" 2>/dev/null || return 1
return 0
}
hist_if_read() { hist_if_read() {
local dir from to files f p local dir from to files f p
dir=$(hist_dir) dir=$(hist_dir)
@@ -955,6 +1004,9 @@ hist_keeper() {
while :; do while :; do
if hist_enabled; then if hist_enabled; then
/usr/local/bin/trafficctl-totals.sh --all >/dev/null 2>&1 /usr/local/bin/trafficctl-totals.sh --all >/dev/null 2>&1
# Both series advance their tmpfs cursor on every tick. Only the
# durable store waits for the flush interval below.
hist_if_accumulate >/dev/null 2>&1
now=$(date +%s) now=$(date +%s)
interval=$(hist_interval) interval=$(hist_interval)
# A clock that jumped backwards (NTP settling after boot, which is # A clock that jumped backwards (NTP settling after boot, which is
@@ -977,6 +1029,10 @@ case "$1" in
# and the thing that makes an hourly flush mean anything. # and the thing that makes an hourly flush mean anything.
hist_enabled || { printf '{"ok":false,"reason":"disabled"}\n'; exit 0; } hist_enabled || { printf '{"ok":false,"reason":"disabled"}\n'; exit 0; }
/usr/local/bin/trafficctl-totals.sh --all >/dev/null 2>&1 /usr/local/bin/trafficctl-totals.sh --all >/dev/null 2>&1
# The interface series advances its own tmpfs cursor on the same tick.
# Leaving it to the flush meant a counter that restarted in between
# lost everything since the last flush, not since the last tick (#111).
hist_if_accumulate >/dev/null 2>&1
printf '{"ok":true,"at":%s}\n' "$(date +%s)" printf '{"ok":true,"at":%s}\n' "$(date +%s)"
;; ;;
flush) hist_flush ;; flush) hist_flush ;;
+11
View File
@@ -0,0 +1,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for vless-core
LUCI_DEPENDS:=+luci-base +vless-core-cli
LUCI_PKGARCH:=all
PKG_LICENSE:=GPL-2.0
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,76 @@
'use strict';
'require view';
'require form';
'require rpc';
var callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: [ 'name' ],
expect: { '': {} }
});
function isRunning(res) {
try {
return Object.keys(res['vless-core'].instances).length > 0;
} catch (e) {
return false;
}
}
return view.extend({
load: function() {
return L.resolveDefault(callServiceList('vless-core'), {});
},
render: function(res) {
var m, s, o;
m = new form.Map('vless-core', _('VLESS Core'),
_('Standalone client for VLESS, Trojan, VMess, Shadowsocks and SOCKS5 upstreams with a local SOCKS5 proxy.') + '<br />' +
'<b>' + _('Status') + ':</b> ' +
(isRunning(res)
? '<span style="color:green">' + _('running') + '</span>'
: '<span style="color:red">' + _('stopped') + '</span>'));
s = m.section(form.NamedSection, 'main', 'vless-core');
s.addremove = false;
o = s.option(form.Flag, 'enabled', _('Enable'));
o.rmempty = false;
o = s.option(form.Value, 'uri', _('Upstream URI'),
_('<code>vless://</code>, <code>vmess://</code>, <code>trojan://</code>, ' +
'<code>ss://</code> or <code>socks5://</code> URI of the upstream server.'));
o.password = true;
o.rmempty = false;
o.validate = function(section_id, value) {
if (value && !/^(vless|vmess|trojan|ss|socks5):\/\//.test(value))
return _('Unsupported URI scheme');
return true;
};
o = s.option(form.Value, 'listen_port', _('SOCKS5 port'),
_('Local SOCKS5 listener, binds to 127.0.0.1 only.'));
o.datatype = 'port';
o.placeholder = '1080';
o = s.option(form.Value, 'routing', _('Routing'),
_('Optional. Format: <code>ENABLED;DEFAULT;LAN;action,type,value;...</code><br />' +
'DEFAULT and action: <code>proxy</code>, <code>direct</code>, <code>block</code>. ' +
'Type: <code>domain</code>, <code>suffix</code>, <code>cidr</code>, <code>port</code>.<br />' +
'Example: <code>1;proxy;1;direct,suffix,ru;block,port,25</code>'));
o.optional = true;
o = s.option(form.ListValue, 'tls_mode', _('Certificate verification'),
_('<code>strict</code>: verified TLS only (needs <code>ca-bundle</code> for security=tls). ' +
'<code>insecure</code>: never verify. ' +
'<code>tofu</code>: pin the certificate on first connection.'));
o.value('strict', _('strict'));
o.value('insecure', _('insecure'));
o.value('tofu', _('tofu'));
o.default = 'strict';
return m.render();
}
});
@@ -0,0 +1,13 @@
{
"admin/services/vless-core": {
"title": "VLESS Core",
"action": {
"type": "view",
"path": "vless-core"
},
"depends": {
"acl": [ "luci-app-vless-core" ],
"uci": { "vless-core": true }
}
}
}
@@ -0,0 +1,12 @@
{
"luci-app-vless-core": {
"description": "Grant access to VLESS Core configuration",
"read": {
"ubus": { "service": [ "list" ] },
"uci": [ "vless-core" ]
},
"write": {
"uci": [ "vless-core" ]
}
}
}
@@ -246,13 +246,23 @@ return view.extend({
Delete — and LuCI's actions column does not wrap, so on a modem Delete — and LuCI's actions column does not wrap, so on a modem
that offered them all the row ran off the right edge of the table that offered them all the row ran off the right edge of the table
(field report with a screenshot, RUTM11). Everything from Tools on (field report with a screenshot, RUTM11). Everything from Tools on
now lives in one dropdown (below), which leaves four. The wrapping now lives in one dropdown (below), which leaves four, laid out
flex row stays as the backstop for a narrow column: the buttons as a fixed two-by-two grid.
keep their theme styling and stay right-aligned, they just fold
onto a second line rather than overflowing. */ Not a wrapping flex row: a button that grows reflows one of
box.style.display = 'flex'; those, and a button DOES grow while its action runs: Config is
box.style.flexWrap = 'wrap'; a createHandlerFn button (form.js:3238), which marks it
box.style.justifyContent = 'flex-end'; `spinning` (ui.js:5683), and Footstrap draws that spinner as a
::before in front of the label (cascade.css:265). A click on
Config then moved every button in the cell for the moment
before the modal opened (luci-app-wwand#18). In a grid the
wider button only widens its column. Each keeps its own width
(justify-items), or Bootstrap's wide Actions dropdown would
stretch Config above it to the same size. */
box.style.display = 'grid';
box.style.gridTemplateColumns = 'repeat(2, auto)';
box.style.justifyContent = 'end';
box.style.justifyItems = 'end';
box.style.gap = '.25em'; box.style.gap = '.25em';
var jump = function(label, title, url) { var jump = function(label, title, url) {
@@ -548,6 +548,13 @@ return baseclass.extend({
before it gets that far */ before it gets that far */
o = s.taboption(tab, form.Value, 'rat_order', _('RAT priority'), o = s.taboption(tab, form.Value, 'rat_order', _('RAT priority'),
_('QMI and MBIM modems: the order in which the modem searches the radio technologies, most preferred first, e.g. <code>nr5g,lte</code>. wwand writes it at every start when the modem holds a different one. A technology missing from the list may never be searched for, even when "Radio technology" allows it. Empty = leave the modem\'s order unchanged. Saving a change restarts this modem\'s bring-up, so its connection drops briefly. The current order is shown, and can be changed live, under Modem Tools.')); _('QMI and MBIM modems: the order in which the modem searches the radio technologies, most preferred first, e.g. <code>nr5g,lte</code>. wwand writes it at every start when the modem holds a different one. A technology missing from the list may never be searched for, even when "Radio technology" allows it. Empty = leave the modem\'s order unchanged. Saving a change restarts this modem\'s bring-up, so its connection drops briefly. The current order is shown, and can be changed live, under Modem Tools.'));
/* an explicit empty entry, like "Modem default" above, rather than the
optional placeholder: ui.js adds that placeholder only when no '' item
exists (ui.js:1180), and Footstrap hides a placeholder while the list
is open (Footstrap cascade.css:829, `:not([open])`), so
the open box had no text and the row, aligned on the baseline, moved
its label down to the box's bottom edge (luci-app-wwand#17) */
o.value('', _('Keep the modem\'s order'));
o.value('nr5g,lte', 'NR5G, LTE'); o.value('nr5g,lte', 'NR5G, LTE');
o.value('nr5g,lte,umts', 'NR5G, LTE, UMTS'); o.value('nr5g,lte,umts', 'NR5G, LTE, UMTS');
o.value('lte,nr5g', 'LTE, NR5G'); o.value('lte,nr5g', 'LTE, NR5G');
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
#
# Copyright (C) 2008-2014 The LuCI Team <luci@lists.subsignal.org>
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
LUCI_TITLE:=Support for hev socks5 tunnel
LUCI_DEPENDS:=+hev-socks5-tunnel +kmod-tun
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,90 @@
'use strict';
'require form';
'require network';
return network.registerProtocol('h2s', {
getI18n: function() {
return _('Hev SOCKS5 Tunnel');
},
getIfname: function() {
return this._ubus('l3_device') || this.sid;
},
getOpkgPackage: function() {
return 'hev-socks5-tunnel';
},
isFloating: function() {
return true;
},
isVirtual: function() {
return true;
},
getDevices: function() {
return null;
},
containsDevice: function(ifname) {
return (network.getIfnameOf(ifname) == this.getIfname());
},
renderFormOptions: function(s) {
var dev = this.getL3Device() || this.getDevice(), o;
o = s.taboption('general', form.Value, 'server', _('SOCKS5 server'),
_('IP address or hostname of the SOCKS5 server'));
o.datatype = 'or(host,ipaddr)';
o.rmempty = false;
o = s.taboption('general', form.Value, 'port', _('SOCKS5 port'));
o.datatype = 'port';
o.placeholder = '1080';
o.rmempty = false;
o = s.taboption('general', form.Value, 'username', _('Username'),
_('Both username and password must be set, or both left empty'));
o.rmempty = true;
o = s.taboption('general', form.Value, 'password', _('Password'));
o.password = true;
o.rmempty = true;
o = s.taboption('general', form.ListValue, 'udp', _('UDP relay mode'));
o.value('udp', _('UDP (native SOCKS5 UDP ASSOCIATE)'));
o.value('tcp', _('UDP over TCP'));
o.default = 'udp';
o = s.taboption('general', form.DynamicList, 'ipaddr', _('Tunnel addresses'),
_('IPv4 and/or IPv6, CIDR notation. Without a mask /32 or /128 is used'));
o.datatype = 'ipmask';
o.rmempty = true;
o = s.taboption('general', form.DynamicList, 'gateway', _('Gateway'),
_('Adds a default route through this address'));
o.datatype = 'ipaddr("nomask")';
o.rmempty = true;
o = s.taboption('advanced', form.Value, 'mtu', _('Override MTU'),
_('Tunnel device maximum transmission unit'));
o.placeholder = '8500';
o.datatype = 'range(576,9200)';
o = s.taboption('advanced', form.Value, 'mark', _('Firewall mark'),
_('Mark for packets of the SOCKS5 connection itself'));
o.datatype = 'uinteger';
o.rmempty = true;
o = s.taboption('advanced', form.Flag, 'tcp_fast_open', _('TCP Fast Open'));
o.rmempty = true;
o = s.taboption('advanced', form.ListValue, 'log_level', _('Log level'));
o.value('debug', _('Debug'));
o.value('info', _('Info'));
o.value('warn', _('Warning'));
o.value('error', _('Error'));
o.default = 'warn';
}
});
+228
View File
@@ -0,0 +1,228 @@
#!/bin/sh
[ -n "$INCLUDE_ONLY" ] || {
. /lib/functions.sh
. /lib/functions/network.sh
. ../netifd-proto.sh
init_proto "$@"
}
proto_h2s_init_config() {
no_device=1
available=1
proto_config_add_array "ipaddr:list(string)"
proto_config_add_array "gateway:list(string)"
proto_config_add_string server
proto_config_add_int port
proto_config_add_string username
proto_config_add_string password
proto_config_add_string udp
proto_config_add_int mtu
proto_config_add_string log_level
proto_config_add_int mark
proto_config_add_boolean tcp_fast_open
proto_config_add_defaults
}
# Write a YAML single-quoted scalar.
# In YAML, a single quote inside a single-quoted scalar is doubled.
h2s_yaml_quote() {
printf "'"
printf '%s' "$1" | sed "s/'/''/g"
printf "'"
}
proto_h2s_setup() {
local interface="$1"
local server port username password
local udp mtu log_level mark tcp_fast_open
local ipaddrs gateways a ad m g src4
local config i
json_get_values ipaddrs ipaddr
json_get_values gateways gateway
json_get_var server server
json_get_var port port
json_get_var username username
json_get_var password password
json_get_var udp udp
json_get_var mtu mtu
json_get_var log_level log_level
json_get_var mark mark
json_get_var tcp_fast_open tcp_fast_open
local ipaddr_type
json_get_type ipaddr_type ipaddr
# Hev uses the logical interface name as its TUN device name.
if [ "${#interface}" -gt 15 ]; then
proto_notify_error "$interface" INVALID_INTERFACE_NAME
return 1
fi
case "$interface" in
""|*[!a-zA-Z0-9_.-]*)
proto_notify_error "$interface" INVALID_INTERFACE_NAME
return 1
;;
esac
[ -n "$server" ] || {
proto_notify_error "$interface" SERVER_MISSING
return 1
}
case "$port" in
""|*[!0-9]*)
proto_notify_error "$interface" INVALID_PORT
return 1
;;
esac
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || {
proto_notify_error "$interface" INVALID_PORT
return 1
}
if [ -n "$username" ] && [ -z "$password" ] ||
[ -z "$username" ] && [ -n "$password" ]; then
proto_notify_error "$interface" INVALID_AUTH
return 1
fi
[ -n "$udp" ] || udp="udp"
case "$udp" in
tcp|udp) ;;
*)
proto_notify_error "$interface" INVALID_UDP_MODE
return 1
;;
esac
[ -n "$mtu" ] || mtu=8500
case "$mtu" in
*[!0-9]*|"")
proto_notify_error "$interface" INVALID_MTU
return 1
;;
esac
[ -n "$log_level" ] || log_level="warn"
case "$log_level" in
debug|info|warn|error) ;;
*)
proto_notify_error "$interface" INVALID_LOG_LEVEL
return 1
;;
esac
config="/var/run/hev-${interface}.yml"
umask 077
mkdir -p /var/run || {
proto_notify_error "$interface" CONFIG_DIR_FAILED
return 1
}
{
cat <<EOF
tunnel:
name: $(h2s_yaml_quote "$interface")
mtu: $mtu
socks5:
address: $(h2s_yaml_quote "$server")
port: $port
udp: $(h2s_yaml_quote "$udp")
EOF
if [ -n "$username" ]; then
printf ' username: '
h2s_yaml_quote "$username"
printf '\n password: '
h2s_yaml_quote "$password"
printf '\n'
fi
if [ -n "$mark" ]; then
case "$mark" in
*[!0-9]*)
exit 1
;;
esac
printf ' mark: %s\n' "$mark"
fi
if [ "$tcp_fast_open" = "1" ]; then
printf ' tcp-fastopen: true\n'
fi
cat <<EOF
misc:
log-level: $(h2s_yaml_quote "$log_level")
EOF
} > "$config" || {
rm -f "$config"
proto_notify_error "$interface" CONFIG_WRITE_FAILED
return 1
}
# Hev must stay in foreground: do not configure misc.pid-file.
proto_run_command "$interface" \
/usr/bin/hev-socks5-tunnel "$config"
# Wait until Hev creates its TUN device.
i=0
while [ "$i" -lt 50 ]; do
[ -d "/sys/class/net/$interface" ] && break
sleep 3
i=$((i + 1))
done
if [ ! -d "/sys/class/net/$interface" ]; then
proto_kill_command "$interface"
rm -f "$config"
proto_notify_error "$interface" TUN_CREATE_FAILED
return 1
fi
proto_init_update "$interface" 1
proto_set_keep 1
for a in $ipaddrs; do
ad="${a%%/*}"
m="${a#*/}"
case "$a" in
*:*)
[ "$m" = "$a" ] && m=128
proto_add_ipv6_address "$ad" "$m"
;;
*)
[ "$m" = "$a" ] && m=32
proto_add_ipv4_address "$ad" "$m"
[ "$src4" ] || src4=$ad
;;
esac
done
for g in $gateways; do
case "$g" in
*:*) proto_add_ipv6_route "::" 0 "$g" ;;
*) proto_add_ipv4_route "0.0.0.0" 0 "$g" "$src4" ;;
esac
done
proto_send_update "$interface"
}
proto_h2s_teardown() {
local interface="$1"
proto_kill_command "$interface"
rm -f "/var/run/hev-${interface}.yml"
}
[ -n "$INCLUDE_ONLY" ] || add_protocol h2s
+43
View File
@@ -1,5 +1,48 @@
# Changelog # Changelog
## [0.3.26-r1] - 2026-11-02
- Bump version for release.
## [0.3.25-r4] - 2026-11-02
### Improved
- Reduce large temporary files during blocklist refreshes and clean up intermediate files after each refresh to lower memory usage on routers.
- Add a lightweight statistics summary method for dashboards that only need aggregate information while preserving the existing full statistics response.
### Tests
- Verify custom allow/block rule precedence, generated blocklist output, and compatibility between the new statistics summary and existing detailed queries.
## [0.3.25-r3] - 2026-11-01
### Improved
- Skip expired-statistics cleanup when the retention boundary is unchanged, and avoid recomputing aggregate totals when statistics have not changed.
- Skip temporary devices with no records in the upload period when preparing recent statistics uploads.
- Batch status initialization and refresh-result updates to reduce repeated reads and writes of the status file.
### Tests
- Verify that retention behavior and upload output remain unchanged and that batched status updates preserve all values.
## [0.3.25-r2] - 2026-11-01
### Fixed
- Harden locking to prevent concurrent statistics collector and automatic refresh runs.
- Avoid unnecessary DNS service restarts when blocklist downloads or preparation fail, preserving existing protection.
- Prevent status file corruption and lost updates during concurrent status writes.
### Improved
- Use built-in OpenWrt tools for locking without requiring a separate `flock` command.
### Tests
- Test lock contention, stale-lock recovery, lock ownership, concurrent status updates, and avoidance of DNS restarts after pre-installation failures.
## [0.3.25-r1] - 2026-10-10 ## [0.3.25-r1] - 2026-10-10
- Bump version for release. - Bump version for release.
+3 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=safeshield PKG_NAME:=safeshield
PKG_VERSION:=0.3.25 PKG_VERSION:=0.3.26
PKG_RELEASE:=4 PKG_RELEASE:=5
PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com> PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com>
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
@@ -80,6 +80,7 @@ define Package/safeshield/install
$(INSTALL_BIN) ./files/usr/lib/safeshield/log.sh $(1)/usr/lib/safeshield/log.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/status.sh $(1)/usr/lib/safeshield/status.sh
$(INSTALL_BIN) ./files/usr/lib/safeshield/utils.sh $(1)/usr/lib/safeshield/utils.sh $(INSTALL_BIN) ./files/usr/lib/safeshield/utils.sh $(1)/usr/lib/safeshield/utils.sh
$(INSTALL_DATA) ./files/usr/lib/safeshield/locking.sh $(1)/usr/lib/safeshield/locking.sh
$(INSTALL_DATA) ./files/usr/lib/safeshield/config.sh $(1)/usr/lib/safeshield/config.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_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/dns.sh $(1)/usr/lib/safeshield/dns.sh
+13
View File
@@ -399,6 +399,7 @@ Available methods:
safeshield.status safeshield.status
safeshield.config safeshield.config
safeshield.statistics safeshield.statistics
safeshield.statistics_summary
safeshield.config_update safeshield.config_update
safeshield.set_enabled safeshield.set_enabled
safeshield.refresh safeshield.refresh
@@ -420,6 +421,18 @@ ubus call safeshield statistics
/etc/init.d/safeshield statistics /etc/init.d/safeshield statistics
``` ```
For dashboards that need collector health and aggregate counters only, use:
```sh
ubus call safeshield statistics_summary
```
The summary retains configuration, service health, source details and totals,
but omits the potentially large `hourly` and `devices` arrays. The original
`statistics` response remains unchanged (schema v3). Both methods read the
current statistics snapshot; the summary specifically reduces RPC-side array
normalization and JSON response size, not the snapshot file read itself.
Statistics are collected from dnsmasq cumulative SafeShield UBus counters. Raw queried domains 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`) 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 queries are excluded so SafeShield's own DNS health checks do not inflate user
+1 -1
View File
@@ -16,7 +16,7 @@ IPKG_INSTROOT="${IPKG_INSTROOT:-}"
export IPKG_INSTROOT export IPKG_INSTROOT
readonly PKG_NAME='safeshield' readonly PKG_NAME='safeshield'
readonly PKG_VERSION='0.3.25-r1' readonly PKG_VERSION='0.3.26-r1'
if type extra_command >/dev/null 2>&1; then 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 'apply_local_rules' 'Apply local allow/block rules using the cached Hub artifact'
@@ -876,7 +876,80 @@ ss_merge_sorted_api_sources() {
sort -m -u "$@" >"$out" sort -m -u "$@" >"$out"
} }
# Build the final blocked-rule stream without two full-sized domain copies.
# Keep the ancestor/equal precedence used by ss_filter_domains_shadowed_by_ancestors:
# local allow > local block > Hub allow > Hub block.
ss_write_effective_block_rules() {
local shadow_file="$1"
local local_blocks="$2"
local api_blocks="$3"
local output="$4"
local prefix="$5"
local suffix="$6"
# With no effective allow rules there is nothing to shadow the Hub blocks.
# Avoid building an AWK lookup table and splitting every domain, but keep
# the same local-first ordering and input validation as the normal path.
if [ ! -s "$shadow_file" ]; then
awk -v local_blocks="$local_blocks" -v prefix="$prefix" -v suffix="$suffix" '
FILENAME == local_blocks {
if ($0 != "") print prefix $0 suffix
next
}
/^[a-z0-9._-]+$/ { print prefix $0 suffix }
' "$local_blocks" "$api_blocks" >"$output"
return $?
fi
awk -v shadow_file="$shadow_file" -v local_blocks="$local_blocks" \
-v prefix="$prefix" -v suffix="$suffix" '
FILENAME == shadow_file {
if ($0 != "") shadow[$0] = 1
next
}
FILENAME == local_blocks {
if ($0 != "") print prefix $0 suffix
next
}
/^[a-z0-9._-]+$/ {
if ($0 in shadow) next
n = split($0, labels, ".")
ancestor = labels[n]
for (i = n - 1; i >= 1; i--) {
ancestor = labels[i] "." ancestor
if (ancestor in shadow) next
}
print prefix $0 suffix
}
' "$shadow_file" "$local_blocks" "$api_blocks" >"$output"
}
# Merge scratch files live on tmpfs. API shards and local lists are retained
# for refresh retry/reuse, but these derived copies are never reused.
ss_cleanup_merge_scratch() {
rm -f \
"${SS_TMP_DIR}/api-blocks.domains" \
"${SS_TMP_DIR}/api-allows.domains" \
"${SS_TMP_DIR}/effective-local-blocks.domains" \
"${SS_TMP_DIR}/effective-api-allows.domains" \
"${SS_TMP_DIR}/effective-allows.domains" \
"${SS_TMP_DIR}/effective-api-blocks.domains" \
"${SS_TMP_DIR}/final-blocks.domains"
}
# Always release derived tmpfs storage, including when validation fails.
ss_merge_lists() { ss_merge_lists() {
local result=0
# Firmware refresh uses the C locale. Enforce it here too so direct
# calls (including CI) retain the exact same bytewise domain order.
local LC_ALL=C
export LC_ALL
ss_merge_lists_inner "$@" || result=$?
ss_cleanup_merge_scratch
return "$result"
}
ss_merge_lists_inner() {
local final local final
local api_blocks="${SS_TMP_DIR}/api-blocks.domains" local api_blocks="${SS_TMP_DIR}/api-blocks.domains"
local api_allows="${SS_TMP_DIR}/api-allows.domains" local api_allows="${SS_TMP_DIR}/api-allows.domains"
@@ -885,10 +958,9 @@ ss_merge_lists() {
local effective_local_blocks="${SS_TMP_DIR}/effective-local-blocks.domains" local effective_local_blocks="${SS_TMP_DIR}/effective-local-blocks.domains"
local effective_api_allows="${SS_TMP_DIR}/effective-api-allows.domains" local effective_api_allows="${SS_TMP_DIR}/effective-api-allows.domains"
local effective_allows="${SS_TMP_DIR}/effective-allows.domains" local effective_allows="${SS_TMP_DIR}/effective-allows.domains"
local effective_api_blocks="${SS_TMP_DIR}/effective-api-blocks.domains"
local final_blocks="${SS_TMP_DIR}/final-blocks.domains"
local final_size_kb local final_size_kb
local valid_count local valid_count
local rule_prefix rule_suffix
final="$(ss_blocklist_tmp_path)" || return 1 final="$(ss_blocklist_tmp_path)" || return 1
: >"$final" || return 1 : >"$final" || return 1
@@ -929,39 +1001,39 @@ ss_merge_lists() {
rm -f "$final" rm -f "$final"
return 1 return 1
} }
ss_filter_domains_shadowed_by_ancestors "$effective_allows" "$api_blocks" "$effective_api_blocks" || { # Produce final rule lines directly rather than materializing both a full
# filtered API blocklist and a second combined domain list. The input API
# shards were already merged and sorted above; the final sort preserves the
# exact dnsmasq rule order/deduplication of the old pipeline.
if ss_dnsmasq_safeshield_block_supported; then
rule_prefix='safeshield-block=/'
rule_suffix='/'
else
rule_prefix='address=/'
rule_suffix='/#'
fi
ss_write_effective_block_rules \
"$effective_allows" "$effective_local_blocks" "$api_blocks" \
"$final" "$rule_prefix" "$rule_suffix" || {
rm -f "$final" rm -f "$final"
return 1 return 1
} }
{ # Sort by the domain field (between slashes), not the whole dnsmasq rule.
[ -f "$effective_local_blocks" ] && cat "$effective_local_blocks" # Sorting full rules changes legacy ordering for domain-prefix pairs such
[ -f "$effective_api_blocks" ] && cat "$effective_api_blocks" # as foo and foo-bar, causing otherwise identical refreshes to differ.
} | sort -u >"$final_blocks" || { # The domain field cannot contain '/' after normalization. -o keeps the
# output in-place without allocating a second full-size domain copy.
# Bound GNU sort's in-memory buffer on low-memory routers. External sort
# runs may use more temporary disk space; the final order is unchanged.
sort -S 1M -u -t/ -k2,2 -o "$final" "$final" || {
rm -f "$final" rm -f "$final"
return 1 return 1
} }
# Preserve the cancellation point from the original merge pipeline.
ss_should_stop && { ss_should_stop && {
rm -f "$final" rm -f "$final"
return 130 return 130
} }
if [ -s "$final_blocks" ]; then
if ss_dnsmasq_safeshield_block_supported; then
awk '{ print "safeshield-block=/" $0 "/" }' "$final_blocks" >"$final" || {
rm -f "$final"
return 1
}
else
# Stock dnsmasq does not understand the SafeShield extension. Preserve
# protection with the standard address rule and leave statistics off.
awk '{ print "address=/" $0 "/#" }' "$final_blocks" >"$final" || {
rm -f "$final"
return 1
}
fi
fi
if [ -s "$effective_allows" ]; then if [ -s "$effective_allows" ]; then
awk '{ print "server=/" $0 "/#" }' "$effective_allows" >>"$final" || { awk '{ print "server=/" $0 "/#" }' "$effective_allows" >>"$final" || {
rm -f "$final" rm -f "$final"
+2 -10
View File
@@ -46,7 +46,6 @@ readonly SS_MAX_ARTIFACT_SOURCES=16
readonly SS_PREV_BLOCKLIST_GZ="/tmp/safeshield.prev.blocklist.gz" readonly SS_PREV_BLOCKLIST_GZ="/tmp/safeshield.prev.blocklist.gz"
readonly SS_RUNTIME_OUT="${SS_TMP_DIR}/runtime.out" readonly SS_RUNTIME_OUT="${SS_TMP_DIR}/runtime.out"
readonly SS_REFRESH_LOCK='/var/lock/safeshield-refresh.lock' 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_LOCAL_APPLY_STATE="${SS_TMP_DIR}/local-apply.state"
readonly SS_STATISTICS_DIR="${SS_TMP_DIR}/statistics" readonly SS_STATISTICS_DIR="${SS_TMP_DIR}/statistics"
readonly SS_STATISTICS_STATE_FILE="${SS_STATISTICS_DIR}/state.tsv" readonly SS_STATISTICS_STATE_FILE="${SS_STATISTICS_DIR}/state.tsv"
@@ -444,7 +443,6 @@ safeshield_apply_local_rules() {
*) *)
log_error "Failed to merge cached Hub artifact with local rules" log_error "Failed to merge cached Hub artifact with local rules"
ss_mark_local_apply_failure "local_merge_failed" ss_mark_local_apply_failure "local_merge_failed"
ss_restore_and_restart
ss_refresh_lock_close ss_refresh_lock_close
return 1 return 1
;; ;;
@@ -647,9 +645,7 @@ safeshield_force_download() {
*) *)
failure_code="$(ss_resolve_error_code)" failure_code="$(ss_resolve_error_code)"
ss_status_mark_failure "$failure_code" ss_status_mark_failure "$failure_code"
if [ "$failure_code" != "safeshield_upgrade_required" ]; then # No blocklist replacement has happened; keep the running DNS service.
ss_restore_and_restart
fi
ss_refresh_lock_close ss_refresh_lock_close
return 1 return 1
;; ;;
@@ -672,9 +668,7 @@ safeshield_force_download() {
*) *)
failure_code="$(ss_artifact_download_error_code)" failure_code="$(ss_artifact_download_error_code)"
ss_status_mark_failure "$failure_code" ss_status_mark_failure "$failure_code"
if [ "$failure_code" != "safeshield_upgrade_required" ]; then # No blocklist replacement has happened; keep the running DNS service.
ss_restore_and_restart
fi
ss_refresh_lock_close ss_refresh_lock_close
return 1 return 1
;; ;;
@@ -689,7 +683,6 @@ safeshield_force_download() {
ss_prepare_local_rule_files || { ss_prepare_local_rule_files || {
log_error "Failed to build allowlist" log_error "Failed to build allowlist"
ss_status_mark_failure "allowlist_build_failed" ss_status_mark_failure "allowlist_build_failed"
ss_restore_and_restart
ss_refresh_lock_close ss_refresh_lock_close
return 1 return 1
} }
@@ -711,7 +704,6 @@ safeshield_force_download() {
*) *)
log_error "Failed to merge API artifact with local overrides" log_error "Failed to merge API artifact with local overrides"
ss_status_mark_failure "merge_failed" ss_status_mark_failure "merge_failed"
ss_restore_and_restart
ss_refresh_lock_close ss_refresh_lock_close
return 1 return 1
;; ;;
@@ -0,0 +1,119 @@
# shellcheck shell=ash
# Directory locks work with the OpenWrt BusyBox base utilities; flock(1) is
# intentionally not required. A pid-less directory is treated as initializing
# (or abandoned) and must age before it can be reclaimed.
ss_lock_pid_alive() {
case "${1:-}" in
'' | *[!0-9]* | 0) return 1 ;;
esac
kill -0 "$1" 2>/dev/null
}
ss_lock_stale() {
local path="$1" owner='' modified='' now=''
[ -d "$path" ] || return 1
if [ -r "$path/pid" ]; then
IFS= read -r owner <"$path/pid" || true
# A live owner always wins, even if the directory appears old.
ss_lock_pid_alive "$owner" && return 1
# Never reclaim an invalid pid that could be mid-write without a grace period.
case "$owner" in
'' | *[!0-9]* | 0) ;;
*) return 0 ;;
esac
fi
# stat is available in the supported BusyBox builds. If it is missing,
# fail closed rather than destroying a possibly initializing lock.
modified="$(stat -c %Y "$path" 2>/dev/null)" || return 1
now="$(date +%s 2>/dev/null)" || return 1
case "$modified:$now" in
*[!0-9:]* | :* | *:) return 1 ;;
esac
[ "$((now - modified))" -ge 30 ] 2>/dev/null
}
ss_lock_reclaim() {
local path="$1" guard="${1}.reclaim" owner=''
# Only one contender may inspect/remove a stale directory at a time.
mkdir "$guard" 2>/dev/null || return 1
if ! ss_lock_stale "$path"; then
rmdir "$guard" 2>/dev/null || true
return 1
fi
if [ -r "$path/pid" ]; then
IFS= read -r owner <"$path/pid" || true
fi
if ss_lock_pid_alive "$owner"; then
rmdir "$guard" 2>/dev/null || true
return 1
fi
# A foreign file makes this fail safely instead of deleting arbitrary data.
rm -f "$path/pid" 2>/dev/null || true
if ! rmdir "$path" 2>/dev/null; then
rmdir "$guard" 2>/dev/null || true
return 1
fi
rmdir "$guard" 2>/dev/null || true
return 0
}
ss_lock_try() {
local path="$1" attempt=0
[ -n "$path" ] || return 1
mkdir -p "${path%/*}" || return 1
while [ "$attempt" -lt 2 ]; do
# Prevent new acquisitions while stale-lock removal is in progress.
if [ -d "${path}.reclaim" ]; then
# A crashed reclaimer must not block all future acquisitions forever.
# Refuse to touch a recent guard still owned by an active reclaimer.
ss_lock_stale "${path}.reclaim" && rmdir "${path}.reclaim" 2>/dev/null || return 1
fi
if mkdir "$path" 2>/dev/null; then
if ! printf '%s\n' "$$" >"$path/pid"; then
rmdir "$path" 2>/dev/null || true
return 1
fi
if [ -e "${path}.reclaim" ]; then
ss_lock_release "$path"
return 1
fi
return 0
fi
[ "$attempt" -eq 0 ] || break
ss_lock_reclaim "$path" || return 1
attempt=$((attempt + 1))
done
return 1
}
ss_lock_wait() {
local path="$1" timeout="${2:-10}" stop_cb="${3:-}" waited=0
case "$timeout" in
'' | *[!0-9]*) timeout=10 ;;
esac
while ! ss_lock_try "$path"; do
if [ "$waited" -ge "$timeout" ]; then
return 1
fi
if [ -n "$stop_cb" ] && "$stop_cb"; then
return 130
fi
sleep 1
waited=$((waited + 1))
done
return 0
}
ss_lock_release() {
local path="$1" owner=''
[ -n "$path" ] && [ -r "$path/pid" ] || return 0
IFS= read -r owner <"$path/pid" || true
[ "$owner" = "$$" ] || return 1
# Do not remove someone else's data even if the lock has extra contents.
rm -f "$path/pid" || return 1
rmdir "$path" 2>/dev/null
}
@@ -30,6 +30,16 @@ function device_bucket_key(key, bucket) {
function prune_buckets(now, cutoff, bucket, composite, parts) { function prune_buckets(now, cutoff, bucket, composite, parts) {
cutoff = hour_start(now) - ((retention_hours - 1) * 3600) cutoff = hour_start(now) - ((retention_hours - 1) * 3600)
# Existing snapshots only add counters to the active hour. Repeating the
# full expired-bucket sweep every minute does no work until the cutoff
# advances. A collector restart starts with an empty cache and prunes once.
if (cutoff == last_prune_cutoff) {
return
}
last_prune_cutoff = cutoff
# A new retention window can invalidate cached totals even without DNS
# traffic; the full aggregate is rebuilt at the next snapshot.
aggregate_dirty = 1
for (bucket in queries) { for (bucket in queries) {
if ((bucket + 0) < cutoff) { if ((bucket + 0) < cutoff) {
delete queries[bucket] delete queries[bucket]
@@ -136,6 +146,7 @@ function register_device(key, mac, ip, hostname, client_id, duid, selected, o
# device record structurally complete, this avoids passing a missing awk # device record structurally complete, this avoids passing a missing awk
# associative-array element to helper functions during serialization. # associative-array element to helper functions during serialization.
device_seen[selected] = 1 device_seen[selected] = 1
aggregate_dirty = 1
device_mac[selected] = "" device_mac[selected] = ""
device_ip[selected] = "" device_ip[selected] = ""
device_hostname[selected] = "" device_hostname[selected] = ""
@@ -282,6 +282,7 @@ function migrate_device(from_key, to_key, mac, ip, hostname, client_id, duid,
if (!journal_replaying) { if (!journal_replaying) {
journal_deleted_device[from_key] = 1 journal_deleted_device[from_key] = 1
} }
aggregate_dirty = 1
remove_device_metadata(from_key) remove_device_metadata(from_key)
selected = register_device(to_key, mac, ip, hostname, client_id, duid) selected = register_device(to_key, mac, ip, hostname, client_id, duid)
if (selected == "") { if (selected == "") {
@@ -533,6 +534,7 @@ function record_device(ip, query_delta, blocked_delta, now, key, bucket, comp
bucket = hour_start(now) bucket = hour_start(now)
composite = device_bucket_key(key, bucket) composite = device_bucket_key(key, bucket)
aggregate_dirty = 1
device_hour_queries[composite] += 0 device_hour_queries[composite] += 0
device_hour_blocked[composite] += 0 device_hour_blocked[composite] += 0
if (query_delta > 0) { if (query_delta > 0) {
@@ -53,6 +53,12 @@ function save_json_range(path, now, first_hour, last_hour, include_empty_devices
if (!is_unknown_output_device(key)) { if (!is_unknown_output_device(key)) {
continue continue
} }
# An upload contains only a short recent window. Ignore provisional
# devices whose retained buckets fall completely outside that window.
if ((device_last_bucket[key] + 0) < first_hour ||
(device_first_bucket[key] + 0) > last_hour) {
continue
}
for (bucket = first_hour; bucket <= last_hour; bucket += 3600) { for (bucket = first_hour; bucket <= last_hour; bucket += 3600) {
composite = device_bucket_key(key, bucket) composite = device_bucket_key(key, bucket)
if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) { if (!((composite in device_hour_queries) || (composite in device_hour_blocked))) {
@@ -228,9 +234,16 @@ function save_snapshot(now, force_persistent, force_snapshot, serialize_snaps
serialize_snapshot = snapshot_dirty || force_snapshot || (force_persistent && persistence_configured) serialize_snapshot = snapshot_dirty || force_snapshot || (force_persistent && persistence_configured)
if (serialize_snapshot) { if (serialize_snapshot) {
prune_buckets(now) prune_buckets(now)
recompute_totals() # Error-only and unchanged UBus snapshots still refresh source metadata
# but do not change retained counters. Avoid reaggregating 128 devices
# across up to 168 hours unless counters, identities, or cutoff changed.
if (aggregate_dirty) {
recompute_totals()
aggregate_dirty = 0
}
if (compact_stale_provisional_devices(now) > 0) { if (compact_stale_provisional_devices(now) > 0) {
recompute_totals() recompute_totals()
aggregate_dirty = 0
} }
updated_at = now updated_at = now
if (!ensure_snapshot_sequence_reservation(now)) { if (!ensure_snapshot_sequence_reservation(now)) {
@@ -9,6 +9,7 @@ function record_event(query_delta, blocked_delta, client_ip, now, bucket) {
now = current_time() now = current_time()
bucket = hour_start(now) bucket = hour_start(now)
snapshot_dirty = 1 snapshot_dirty = 1
aggregate_dirty = 1
queries[bucket] += 0 queries[bucket] += 0
blocked[bucket] += 0 blocked[bucket] += 0
@@ -110,6 +111,7 @@ function apply_source_snapshot( now, bucket, had_source, same_epoch, query_de
} }
if (query_delta > 0 || blocked_delta > 0) { if (query_delta > 0 || blocked_delta > 0) {
aggregate_dirty = 1
queries[bucket] += 0 queries[bucket] += 0
blocked[bucket] += 0 blocked[bucket] += 0
queries[bucket] += query_delta queries[bucket] += query_delta
@@ -184,6 +186,9 @@ BEGIN {
} }
event_index = 0 event_index = 0
snapshot_dirty = 0 snapshot_dirty = 0
# BEGIN restores state and reconciles device identities before the first
# snapshot, so never trust persisted aggregates as an in-memory cache.
aggregate_dirty = 1
device_count = 0 device_count = 0
devices_truncated = 0 devices_truncated = 0
last_lease_refresh = 0 last_lease_refresh = 0
@@ -62,6 +62,19 @@ else if (cmd == 'set') {
obj.data[key] = value; obj.data[key] = value;
} }
else if (cmd == 'set_many') {
// Reject incomplete input before modifying any field; one JSON write is
// committed only after every key/value pair has been validated.
if (length(ARGV) < 4 || (length(ARGV) - 2) % 2 != 0)
exit(2);
for (let i = 2; i < length(ARGV); i += 2) {
if (!ARGV[i])
exit(2);
}
for (let i = 2; i < length(ARGV); i += 2)
obj.data[ARGV[i]] = ARGV[i + 1];
}
else if (cmd == 'add_error') { else if (cmd == 'add_error') {
let code = ARGV[2]; let code = ARGV[2];
+90 -118
View File
@@ -10,26 +10,19 @@ ss_status_exec() {
} }
ss_status_apply() { ss_status_apply() {
local tmp="${RUNNING_STATUS_FILE}.tmp" local tmp="${RUNNING_STATUS_FILE}.tmp.$$" rc=0
mkdir -p "${RUNNING_STATUS_FILE%/*}" || return 1 mkdir -p "${RUNNING_STATUS_FILE%/*}" || return 1
json_lock_open || return 1
ss_status_exec "$@" >"${tmp}" || { ss_status_exec "$@" >"$tmp" || rc=1
rm -f "${tmp}" [ "$rc" -eq 0 ] && [ -s "$tmp" ] || rc=1
return 1 if [ "$rc" -eq 0 ]; then
} mv -f "$tmp" "$RUNNING_STATUS_FILE" || rc=1
fi
[ -s "${tmp}" ] || { [ "$rc" -eq 0 ] || rm -f "$tmp"
rm -f "${tmp}" json_lock_close
return 1 return "$rc"
}
mv -f "${tmp}" "${RUNNING_STATUS_FILE}" || {
rm -f "${tmp}"
return 1
}
return 0
} }
ss_now() { ss_now() {
@@ -40,66 +33,77 @@ ss_status_set_now() {
ss_status_set "$1" "$(ss_now)" ss_status_set "$1" "$(ss_now)"
} }
# A single status-store invocation updates all pairs while holding the same
# JSON lock. This avoids spawning ucode once per field during a refresh reset.
ss_status_set_many() {
[ "$#" -gt 0 ] || return 0
ss_status_apply set_many "$@" >/dev/null 2>&1 || true
}
ss_status_reset_health_fields() { ss_status_reset_health_fields() {
ss_status_set health_dnsmasq_binary "" ss_status_set_many \
ss_status_set health_dnsmasq_version "" health_dnsmasq_binary "" \
ss_status_set health_dnsmasq_features "" health_dnsmasq_version "" \
ss_status_set health_dnsmasq_confdir "" health_dnsmasq_features "" \
ss_status_set health_dnsmasq_initial_restart "" health_dnsmasq_confdir "" \
ss_status_set health_dnsmasq_final_restart "" health_dnsmasq_initial_restart "" \
ss_status_set health_dns_runtime "" health_dnsmasq_final_restart "" \
ss_status_set health_blocklist_verify "" health_dns_runtime "" \
ss_status_set health_min_valid_line_count "" health_blocklist_verify "" \
ss_status_set health_max_file_size "" health_min_valid_line_count "" \
ss_status_set health_api_resolve "" health_max_file_size "" \
ss_status_set health_artifact_download "" health_api_resolve "" \
ss_status_set health_artifact_sha256 "" health_artifact_download "" \
ss_status_set dnsmasq_version "" health_artifact_sha256 "" \
ss_status_set dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION:-2.93}" dnsmasq_version "" \
dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION:-2.93}"
} }
ss_status_reset_blocklist_fields() { ss_status_reset_blocklist_fields() {
ss_status_set blocklist_installed "0" ss_status_set_many \
ss_status_set blocklist_file_size_kb "0" blocklist_installed "0" \
ss_status_set blocklist_verification_ok "0" blocklist_file_size_kb "0" \
ss_status_set blocklist_test_domain "" blocklist_verification_ok "0" \
ss_status_set blocklist_test_domain_sample_count "0" blocklist_test_domain "" \
ss_status_set blocklist_test_domain_success_count "0" blocklist_test_domain_sample_count "0" \
ss_status_set blocklist_test_domains "" blocklist_test_domain_success_count "0" \
ss_status_set blocklist_backup_available "0" blocklist_test_domains "" \
blocklist_backup_available "0"
} }
ss_status_reset_artifact_fields() { ss_status_reset_artifact_fields() {
ss_status_set entitlement_plan "" ss_status_set_many \
ss_status_set entitlement_status "" entitlement_plan "" \
ss_status_set physical_fingerprint "" entitlement_status "" \
ss_status_set fingerprint_version "" physical_fingerprint "" \
ss_status_set identity_provider "" fingerprint_version "" \
ss_status_set identity_source "" identity_provider "" \
ss_status_set identity_strength "" identity_source "" \
ss_status_set identity_profile "" identity_strength "" \
ss_status_set device_code "" identity_profile "" \
ss_status_set device_code_source "" device_code "" \
ss_status_set installation_id "" device_code_source "" \
ss_status_set device_profile "" installation_id "" \
ss_status_set artifact_tier "" device_profile "" \
ss_status_set artifact_version "" artifact_tier "" \
ss_status_set artifact_sha256 "" artifact_version "" \
ss_status_set artifact_unique_domains "0" artifact_sha256 "" \
ss_status_set artifact_rules "0" artifact_unique_domains "0" \
ss_status_set artifact_download_url_present "0" artifact_rules "0" \
ss_status_set artifact_source_count "0" artifact_download_url_present "0" \
ss_status_set artifact_block_source_count "0" artifact_source_count "0" \
ss_status_set artifact_allow_source_count "0" artifact_block_source_count "0" \
artifact_allow_source_count "0"
} }
ss_status_mark_failure() { ss_status_mark_failure() {
local code="$1" local code="$1"
ss_status_set status "error" ss_status_set_many \
ss_status_set_now last_failure status "error" \
ss_status_set last_result "error" last_failure "$(ss_now)" \
ss_status_set last_error_code "$code" last_result "error" \
last_error_code "$code"
ss_status_add_error "$code" ss_status_add_error "$code"
} }
@@ -109,14 +113,15 @@ ss_status_mark_success() {
now="$(ss_now)" now="$(ss_now)"
ss_status_apply clear_messages >/dev/null 2>&1 || true 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 if is_valid_integer "$interval" && [ "$interval" -gt 0 ] 2>/dev/null; then
ss_status_set next_refresh_at "$((now + interval))" ss_status_set_many \
status "ready" stage "done" last_success "$now" \
last_result "success" last_error_code "" \
next_refresh_at "$((now + interval))"
else
ss_status_set_many \
status "ready" stage "done" last_success "$now" \
last_result "success" last_error_code ""
fi fi
} }
@@ -131,9 +136,10 @@ ss_status_prepare_refresh() {
is_valid_integer "$last_local_apply_failure" || last_local_apply_failure=0 is_valid_integer "$last_local_apply_failure" || last_local_apply_failure=0
ss_status_reset ss_status_reset
ss_status_set last_success "$last_success" ss_status_set_many \
ss_status_set last_local_apply "$last_local_apply" last_success "$last_success" \
ss_status_set last_local_apply_failure "$last_local_apply_failure" last_local_apply "$last_local_apply" \
last_local_apply_failure "$last_local_apply_failure"
} }
ss_status_set() { ss_status_set() {
@@ -159,22 +165,14 @@ ss_status_add_warning() {
} }
ss_status_reset() { ss_status_reset() {
rm -f "${RUNNING_STATUS_FILE}" "${RUNNING_STATUS_FILE}.tmp"
ss_status_apply reset >/dev/null 2>&1 || return 1 ss_status_apply reset >/dev/null 2>&1 || return 1
ss_status_set status "idle" ss_status_set_many \
ss_status_set stage "" status "idle" stage "" valid_line_count "0" \
ss_status_set valid_line_count "0" last_result "idle" last_error_code "" \
last_attempt "0" last_success "0" last_failure "0" \
ss_status_set last_result "idle" last_local_apply "0" last_local_apply_failure "0" \
ss_status_set last_error_code "" next_refresh_at "0"
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_health_fields
ss_status_reset_blocklist_fields ss_status_reset_blocklist_fields
@@ -262,41 +260,15 @@ ss_restore_previous_blocklist() {
} }
ss_refresh_lock_open() { ss_refresh_lock_open() {
mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1 ss_lock_try "${SS_REFRESH_LOCK}.d"
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() { ss_refresh_lock_open_wait() {
local timeout="${1:-120}" local timeout="${1:-120}"
local waited=0
is_valid_integer "$timeout" || timeout=120 is_valid_integer "$timeout" || timeout=120
mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1 ss_lock_wait "${SS_REFRESH_LOCK}.d" "$timeout" ss_should_stop
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() { ss_refresh_lock_close() {
flock -u "${SS_REFRESH_LOCK_FD}" 2>/dev/null || true ss_lock_release "${SS_REFRESH_LOCK}.d"
eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true
} }
+20 -12
View File
@@ -1,7 +1,9 @@
# shellcheck shell=ash # shellcheck shell=ash
readonly LOCK_FD=309 readonly RUNNING_STATUS_LOCK="/var/lock/${PKG_NAME}.lock.d"
readonly RUNNING_STATUS_LOCK="/var/lock/${PKG_NAME}.lock"
# shellcheck disable=SC1090
. "${SS_LOCKING_LIB:-${IPKG_INSTROOT:-}/usr/lib/safeshield/locking.sh}"
readonly RUNNING_STATUS_FILE="/dev/shm/${PKG_NAME}.status.json" readonly RUNNING_STATUS_FILE="/dev/shm/${PKG_NAME}.status.json"
command_exists() { command_exists() {
@@ -21,17 +23,23 @@ is_greater_equal() {
} }
json_lock_open() { json_lock_open() {
mkdir -p "${RUNNING_STATUS_LOCK%/*}" || return 1 ss_lock_wait "$RUNNING_STATUS_LOCK" 10
eval "exec ${LOCK_FD}>\"${RUNNING_STATUS_LOCK}\"" || return 1
flock -x "${LOCK_FD}" || {
eval "exec ${LOCK_FD}>&-"
return 1
}
} }
json_lock_close() { json_lock_close() {
flock -u "${LOCK_FD}" 2>/dev/null || true ss_lock_release "$RUNNING_STATUS_LOCK"
eval "exec ${LOCK_FD}>&-" 2>/dev/null || true }
_json_commit() {
local tmp="${RUNNING_STATUS_FILE}.tmp.$$"
json_dump >"$tmp" || {
rm -f "$tmp"
return 1
}
mv -f "$tmp" "$RUNNING_STATUS_FILE" || {
rm -f "$tmp"
return 1
}
} }
_json_init_data_object() { _json_init_data_object() {
@@ -62,7 +70,7 @@ _json_set() {
json_add_string "$key" "$value" json_add_string "$key" "$value"
json_select .. 2>/dev/null || true json_select .. 2>/dev/null || true
json_dump >"${RUNNING_STATUS_FILE}" _json_commit
} }
_json_add_message() { _json_add_message() {
@@ -82,7 +90,7 @@ _json_add_message() {
json_add_string info "$value" json_add_string info "$value"
json_close_object json_close_object
json_select .. 2>/dev/null || true json_select .. 2>/dev/null || true
json_dump >"${RUNNING_STATUS_FILE}" _json_commit
} }
json() { json() {
@@ -14,20 +14,26 @@ export IPKG_INSTROOT
. /usr/lib/safeshield/core.sh . /usr/lib/safeshield/core.sh
readonly SS_REFRESHD_LOCK='/var/lock/safeshield-refreshd.lock' readonly SS_REFRESHD_LOCK='/var/lock/safeshield-refreshd.lock'
readonly SS_REFRESHD_LOCK_FD=308 refreshd_owned=0
ss_should_terminate=0 ss_should_terminate=0
ss_sleep_pid='' ss_sleep_pid=''
refreshd_lock() { refreshd_lock() {
mkdir -p "${SS_REFRESHD_LOCK%/*}" || return 1 ss_lock_try "${SS_REFRESHD_LOCK}.d" || return 1
eval "exec ${SS_REFRESHD_LOCK_FD}>\"${SS_REFRESHD_LOCK}\"" || return 1 refreshd_owned=1
flock -n "${SS_REFRESHD_LOCK_FD}" || return 1
} }
refreshd_unlock() { refreshd_unlock() {
flock -u "${SS_REFRESHD_LOCK_FD}" 2>/dev/null || true [ "${refreshd_owned:-0}" -eq 1 ] || return 0
eval "exec ${SS_REFRESHD_LOCK_FD}>&-" 2>/dev/null || true ss_lock_release "${SS_REFRESHD_LOCK}.d"
refreshd_owned=0
}
refreshd_cleanup() {
[ "${refreshd_owned:-0}" -eq 1 ] || return 0
clear_next_refresh_at
refreshd_unlock
} }
handle_term() { handle_term() {
@@ -253,6 +259,6 @@ main() {
} }
trap "handle_term" TERM INT trap "handle_term" TERM INT
trap "refreshd_unlock; clear_next_refresh_at" EXIT trap "refreshd_cleanup" EXIT
main "$@" main "$@"
+7 -21
View File
@@ -7,6 +7,7 @@ LC_ALL=C
export LC_ALL export LC_ALL
umask 077 umask 077
SS_STATSD_LOCK_LIB="${SS_STATSD_LOCK_LIB:-/usr/lib/safeshield/locking.sh}"
SS_STATSD_FUNCTIONS_LIB="${SS_STATSD_FUNCTIONS_LIB:-/lib/functions.sh}" 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_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_AWK_DIR="${SS_STATSD_AWK_DIR:-/usr/lib/safeshield/statistics}"
@@ -24,6 +25,8 @@ SS_STATSD_PERSISTENT_COMPACT_INTERVAL_S="${SS_STATSD_PERSISTENT_COMPACT_INTERVAL
. "$SS_STATSD_FUNCTIONS_LIB" . "$SS_STATSD_FUNCTIONS_LIB"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$SS_STATSD_CORE_LIB" . "$SS_STATSD_CORE_LIB"
# shellcheck disable=SC1090
. "$SS_STATSD_LOCK_LIB"
stats_poller_pid='' stats_poller_pid=''
stats_awk_pid='' stats_awk_pid=''
@@ -74,7 +77,8 @@ stats_cleanup() {
[ -n "$stats_fifo" ] && rm -f "$stats_fifo" [ -n "$stats_fifo" ] && rm -f "$stats_fifo"
if [ "$stats_lock_owned" -eq 1 ] && [ -n "$stats_lock_dir" ]; then if [ "$stats_lock_owned" -eq 1 ] && [ -n "$stats_lock_dir" ]; then
rm -rf "$stats_lock_dir" ss_lock_release "$stats_lock_dir" || true
stats_lock_owned=0
fi fi
} }
@@ -85,30 +89,12 @@ stats_terminate() {
} }
stats_acquire_lock() { stats_acquire_lock() {
local owner_pid=''
stats_lock_dir="${SS_STATISTICS_DIR}/collector.lock" stats_lock_dir="${SS_STATISTICS_DIR}/collector.lock"
if ! ss_lock_try "$stats_lock_dir"; then
if mkdir "$stats_lock_dir" 2>/dev/null; then log_warn 'Statistics collector lock is already held or unavailable'
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 return 1
fi 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 stats_lock_owned=1
printf '%s\n' "$$" >"${stats_lock_dir}/pid"
return 0 return 0
} }
@@ -7,6 +7,7 @@
"status", "status",
"config", "config",
"statistics", "statistics",
"statistics_summary",
"rules_list" "rules_list"
] ]
} }
@@ -24,6 +24,10 @@ return {
call: statistics.build call: statistics.build
}, },
statistics_summary: {
call: statistics.summary
},
config_update: { config_update: {
args: { args: {
values: {} values: {}
@@ -84,7 +84,7 @@ function sanitize_source(item) {
}; };
} }
function build_statistics() { function build_statistics(include_detail) {
reload_uci(); reload_uci();
let enabled = to_bool(cfg('statistics_enabled', '1'), true); let enabled = to_bool(cfg('statistics_enabled', '1'), true);
let retention_hours = to_int(cfg('statistics_retention_hours', '168'), 168); let retention_hours = to_int(cfg('statistics_retention_hours', '168'), 168);
@@ -98,7 +98,7 @@ function build_statistics() {
if (!effective_enabled) { if (!effective_enabled) {
source.available = false; source.available = false;
} }
return { let result = {
schema: { schema: {
name: STATISTICS_SCHEMA_NAME, name: STATISTICS_SCHEMA_NAME,
version: STATISTICS_SCHEMA_VERSION version: STATISTICS_SCHEMA_VERSION
@@ -133,13 +133,26 @@ function build_statistics() {
totals: { totals: {
queries: to_int(totals.queries, 0), queries: to_int(totals.queries, 0),
blocked: to_int(totals.blocked, 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)
}; };
// Preserve the legacy detailed RPC field insertion order as well as
// names/values: totals -> hourly -> device_limit -> devices_truncated ->
// devices. Summary callers omit only the two large detail arrays.
if (include_detail !== false) {
result.hourly = sanitize_hourly(data.hourly);
}
result.device_limit = to_int(data.device_limit, 128);
result.devices_truncated = to_bool(data.devices_truncated, false);
if (include_detail !== false) {
result.devices = sanitize_devices(data.devices);
}
return result;
}
function build_statistics_summary() {
return build_statistics(false);
} }
return { return {
build: build_statistics build: build_statistics,
summary: build_statistics_summary
}; };
+21
View File
@@ -8,6 +8,27 @@ Describe 'SafeShield blocklist behavior'
The error should equal '' The error should equal ''
End End
It 'builds large lists with bounded merge scratch files and preserves precedence'
When call ss_case_blocklist_tmpfs
The status should be success
The output should equal ''
The error should equal ''
End
It 'preserves the exact legacy domain order for both dnsmasq modes'
When call ss_case_blocklist_legacy_order
The status should be success
The output should equal ''
The error should equal ''
End
It 'filters block rules with bounded-memory lookup and no-allow fast path'
When call ss_case_blocklist_low_memory
The status should be success
The output should equal ''
The error should equal ''
End
It 'resolves and downloads multiple Hub artifact sources' It 'resolves and downloads multiple Hub artifact sources'
When call ss_case_multi_artifact When call ss_case_multi_artifact
The status should be success The status should be success
+7
View File
@@ -50,6 +50,13 @@ Describe 'SafeShield core shell behavior'
The error should equal '' The error should equal ''
End End
It 'updates status fields in batches rather than one process per key'
When call ss_case_status_batch_writes
The status should be success
The output should equal ''
The error should equal ''
End
It 'removes preserved legacy license keys during upgrade' It 'removes preserved legacy license keys during upgrade'
When call ss_case_legacy_license_cleanup When call ss_case_legacy_license_cleanup
The status should be success The status should be success
+1 -1
View File
@@ -1,7 +1,7 @@
# shellcheck shell=sh # shellcheck shell=sh
Describe 'SafeShield lifecycle and refresh state machines' Describe 'SafeShield lifecycle and refresh state machines'
It 'rolls back full refresh failures while preserving the active blocklist for upgrade-required responses' It 'keeps DNS running before installation failures and rolls back post-install failures'
When call ss_case_refresh_state_machine When call ss_case_refresh_state_machine
The status should be success The status should be success
The output should equal '' The output should equal ''
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# shellcheck shell=sh
Describe 'SafeShield directory locking and state consistency'
It 'prevents lock theft, reclaims stale owners and respects ownership'
When call ss_case_lock_owner_and_contention
The status should be success
The output should equal ''
The error should equal ''
End
It 'serializes concurrent operations using BusyBox-compatible locks'
When call ss_case_lock_parallel
The status should be success
The output should equal ''
The error should equal ''
End
It 'serializes status read-modify-write operations under the same lock'
When call ss_case_status_write_serialization
The status should be success
The output should equal ''
The error should equal ''
End
It 'does not corrupt the previous status when serialization fails'
When call ss_case_status_write_failure
The status should be success
The output should equal ''
The error should equal ''
End
End
+2
View File
@@ -16,6 +16,8 @@ export SS_SPEC_ROOT
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/lifecycle_cases.sh" . "$SS_SPEC_ROOT/spec/support/lifecycle_cases.sh"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/locking_cases.sh"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/blocklist_cases.sh" . "$SS_SPEC_ROOT/spec/support/blocklist_cases.sh"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$SS_SPEC_ROOT/spec/support/device_api_cases.sh" . "$SS_SPEC_ROOT/spec/support/device_api_cases.sh"
+7
View File
@@ -119,6 +119,13 @@ Describe 'SafeShield statistics behavior'
The error should equal '' The error should equal ''
End End
It 'prunes expired statistics while preserving the short upload window'
When call ss_case_statistics_retention_cache
The status should be success
The output should equal ''
The error should equal ''
End
It 'loads the statistics collector from focused awk modules' It 'loads the statistics collector from focused awk modules'
When call ss_case_statistics_modules When call ss_case_statistics_modules
The status should be success The status should be success
+197
View File
@@ -140,6 +140,203 @@ sample-two.example'
ss_spec_assert_eq "$DNS_CHECK_COUNT" '2' ss_spec_assert_eq "$DNS_CHECK_COUNT" '2'
) )
# A larger, overlapping fixture exercises sorted streaming, tmpfs cleanup and
# precedence without downloading any external artifact.
ss_case_blocklist_tmpfs() (
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=1
ss_max_blocklist_file_size_kb=1024
ss_dnsmasq_sanity_check=0
mkdir -p "$SS_TMP_DIR" "$SS_DNSMASQ_DIR"
ss_should_stop() { [ "${STOP_NOW:-0}" = 1 ]; }
ss_status_set() { :; }
ss_status_add_error() { :; }
log_info() { :; }
log_error() { :; }
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"
awk 'BEGIN { for (i = 1; i <= 6000; i++) printf "ads%05d.example\n", i }' >"$SS_TMP_DIR/api.0.block.txt"
awk 'BEGIN { for (i = 4000; i <= 10000; i++) printf "ads%05d.example\n", i }' >"$SS_TMP_DIR/api.1.block.txt"
printf '%s\n' 'ads00042.example' 'hub-allow.example' >"$SS_TMP_DIR/api.2.allow.txt"
printf '%s\n' 'hub-allow.example' 'local.example' >"$SS_TMP_DIR/local.block.txt"
printf '%s\n' 'local.example' 'ads00042.example' >"$SS_TMP_DIR/local.allow.txt"
# Prior implementations left 7 merge scratch files on tmpfs. Even stale
# files from a previous run must be released after this refresh.
printf 'stale\n' >"$SS_TMP_DIR/effective-api-blocks.domains"
printf 'stale\n' >"$SS_TMP_DIR/final-blocks.domains"
ss_merge_lists
ss_spec_assert_eq "$(grep -c '^safeshield-block=/' "$SS_BLOCKLIST_FILE")" '10000'
ss_spec_assert_eq "$(grep -c '^server=/' "$SS_BLOCKLIST_FILE")" '2'
ss_spec_assert_file_contains "$SS_BLOCKLIST_FILE" 'safeshield-block=/hub-allow.example/'
ss_spec_assert_file_not_contains "$SS_BLOCKLIST_FILE" 'safeshield-block=/ads00042.example/'
ss_spec_assert_file_line "$SS_BLOCKLIST_FILE" 'server=/ads00042.example/#'
ss_spec_assert_file_line "$SS_BLOCKLIST_FILE" 'server=/local.example/#'
# Cached source shards and local rules must not be deleted by cleanup.
ss_spec_assert_exists "$SS_TMP_DIR/api.0.block.txt"
ss_spec_assert_exists "$SS_TMP_DIR/local.block.txt"
for derived in \
api-blocks.domains api-allows.domains \
effective-local-blocks.domains effective-api-allows.domains \
effective-allows.domains effective-api-blocks.domains final-blocks.domains; do
ss_spec_assert_not_exists "$SS_TMP_DIR/$derived"
done
# Stock dnsmasq rules retain identical precedence and order.
DNSMASQ_PATCHED=0
ss_merge_lists
ss_spec_assert_eq "$(grep -c '^address=/' "$SS_BLOCKLIST_FILE")" '10000'
ss_spec_assert_file_line "$SS_BLOCKLIST_FILE" 'address=/hub-allow.example/#'
ss_spec_assert_file_not_contains "$SS_BLOCKLIST_FILE" 'address=/ads00042.example/#'
for derived in api-blocks.domains effective-api-blocks.domains final-blocks.domains; do
ss_spec_assert_not_exists "$SS_TMP_DIR/$derived"
done
# A cancelled refresh must not install partial rules or retain scratch.
before="$(cksum "$SS_BLOCKLIST_FILE")"
STOP_NOW=1
result=0
ss_merge_lists || result=$?
ss_spec_assert_eq "$result" '130'
ss_spec_assert_eq "$(cksum "$SS_BLOCKLIST_FILE")" "$before"
ss_spec_assert_not_exists "$SS_TMP_DIR/api-blocks.domains"
ss_spec_assert_not_exists "$SS_TMP_DIR/effective-api-blocks.domains"
)
# Print an actionable diff if this ordering contract regresses in CI.
ss_assert_legacy_blocklist_order() (
# Use a subshell to keep these variables scoped under POSIX sh.
expected="$1"
actual_file="$2"
expected_file="${actual_file}.expected.$$"
printf '%s\n' "$expected" >"$expected_file"
if ! cmp -s "$expected_file" "$actual_file"; then
diff -u "$expected_file" "$actual_file" >&2 || :
rm -f "$expected_file"
return 1
fi
rm -f "$expected_file"
)
# Ordering is part of our byte-for-byte legacy blocklist compatibility.
# Sorting decorated dnsmasq rule strings changes prefix ordering ("foo" vs
# "foo-bar" / "foo.bar"); always compare by the undressed domain field.
ss_case_blocklist_legacy_order() (
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
mkdir -p "$SS_TMP_DIR" "$SS_DNSMASQ_DIR"
ss_should_stop() { return 1; }
ss_status_set() { :; }
ss_status_add_error() { :; }
log_info() { :; }
log_error() { :; }
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"
# API shards are pre-sorted by ss_download_api_artifacts(). Keep this
# fixture representative of the actual -m (merge) precondition.
printf '%s\n' 'abc.test' 'foo' 'foo-bar' 'foo.bar' 'foo_bar' |
LC_ALL=C sort -u >"$SS_TMP_DIR/api.0.block.txt"
printf '%s\n' 'abc.test-more' 'abc.test.uk' |
LC_ALL=C sort -u >"$SS_TMP_DIR/api.1.block.txt"
ss_merge_lists
expected='safeshield-block=/abc.test/
safeshield-block=/abc.test-more/
safeshield-block=/abc.test.uk/
safeshield-block=/foo/
safeshield-block=/foo-bar/
safeshield-block=/foo.bar/
safeshield-block=/foo_bar/'
ss_assert_legacy_blocklist_order "$expected" "$SS_BLOCKLIST_FILE"
DNSMASQ_PATCHED=0
ss_merge_lists
expected='address=/abc.test/#
address=/abc.test-more/#
address=/abc.test.uk/#
address=/foo/#
address=/foo-bar/#
address=/foo.bar/#
address=/foo_bar/#'
ss_assert_legacy_blocklist_order "$expected" "$SS_BLOCKLIST_FILE"
)
# Both low-memory filtering paths must retain the legacy allow precedence and
# the original local-block-first rule stream. The final merge sorts afterwards.
ss_case_blocklist_low_memory() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
mkdir -p "$TMP_DIR"
SS_TMP_DIR="$TMP_DIR"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
cat >"$TMP_DIR/blocks" <<'DATA'
ads.example
foo
foo-bar
sub.ads.example
invalid/path
CAPS.example
valid.example
DATA
printf '%s\n' 'local.example' >"$TMP_DIR/local"
: >"$TMP_DIR/shadow"
ss_write_effective_block_rules \
"$TMP_DIR/shadow" "$TMP_DIR/local" "$TMP_DIR/blocks" \
"$TMP_DIR/result" 'safeshield-block=/' '/'
expected='safeshield-block=/local.example/
safeshield-block=/ads.example/
safeshield-block=/foo/
safeshield-block=/foo-bar/
safeshield-block=/sub.ads.example/
safeshield-block=/valid.example/'
ss_spec_assert_eq "$(cat "$TMP_DIR/result")" "$expected"
# A present allow shadows the matching Hub block and its descendants, but
# does not remove an explicit local block or unrelated Hub domains.
printf '%s\n' 'foo' 'ads.example' >"$TMP_DIR/shadow"
ss_write_effective_block_rules \
"$TMP_DIR/shadow" "$TMP_DIR/local" "$TMP_DIR/blocks" \
"$TMP_DIR/result" 'address=/' '/#'
expected='address=/local.example/#
address=/foo-bar/#
address=/valid.example/#'
ss_spec_assert_eq "$(cat "$TMP_DIR/result")" "$expected"
# When the local list is empty, the no-allow fast path must still process
# Hub block records (including the first record of the second input file).
: >"$TMP_DIR/local"
: >"$TMP_DIR/shadow"
ss_write_effective_block_rules \
"$TMP_DIR/shadow" "$TMP_DIR/local" "$TMP_DIR/blocks" \
"$TMP_DIR/result" 'address=/' '/#'
ss_spec_assert_eq "$(head -n 1 "$TMP_DIR/result")" 'address=/ads.example/#'
ss_spec_assert_eq "$(grep -c '^address=/' "$TMP_DIR/result")" '5'
BLOCKLIST="$SS_SPEC_ROOT/files/usr/lib/safeshield/blocklist.sh"
ss_spec_assert_file_contains "$BLOCKLIST" 'sort -S 1M -u -t/ -k2,2 -o "$final" "$final"'
)
ss_case_multi_artifact() ( ss_case_multi_artifact() (
set -eu set -eu
TMP_DIR="$(ss_spec_tmpdir)" TMP_DIR="$(ss_spec_tmpdir)"
+53 -2
View File
@@ -6,6 +6,7 @@ ss_case_utils() (
PKG_NAME='safeshield-test' PKG_NAME='safeshield-test'
export PKG_NAME export PKG_NAME
# shellcheck disable=SC1091 # shellcheck disable=SC1091
SS_LOCKING_LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh"
command_exists sh command_exists sh
@@ -25,6 +26,7 @@ ss_case_config_validation() (
PKG_NAME='safeshield-test' PKG_NAME='safeshield-test'
export PKG_NAME export PKG_NAME
# shellcheck disable=SC1091 # shellcheck disable=SC1091
SS_LOCKING_LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh"
WARNINGS='' WARNINGS=''
@@ -121,6 +123,7 @@ ss_case_dnsmasq_version() (
PKG_NAME='safeshield' PKG_NAME='safeshield'
export PKG_NAME export PKG_NAME
# shellcheck disable=SC1091 # shellcheck disable=SC1091
SS_LOCKING_LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh"
DNSMASQ_FEATURE_HEALTH='' DNSMASQ_FEATURE_HEALTH=''
ss_status_set() { ss_status_set() {
@@ -378,6 +381,15 @@ ss_case_status_state() (
record() { printf '%s\t%s\t%s\n' "$1" "${2:-}" "${3:-}" >>"$CALLS"; } record() { printf '%s\t%s\t%s\n' "$1" "${2:-}" "${3:-}" >>"$CALLS"; }
ss_status_set() { record set "$1" "$2"; } ss_status_set() { record set "$1" "$2"; }
ss_status_add_error() { record error "$1"; } ss_status_add_error() { record error "$1"; }
# Capture each value for existing state assertions while recording that a
# whole group is now committed through one locked status-store operation.
ss_status_set_many() {
record batch set_many "$#"
while [ "$#" -ge 2 ]; do
record set "$1" "$2"
shift 2
done
}
ss_status_apply() { ss_status_apply() {
record apply "$1" "${2:-}" record apply "$1" "${2:-}"
return 0 return 0
@@ -388,6 +400,7 @@ ss_case_status_state() (
[ "$1" -ge 0 ] 2>/dev/null [ "$1" -ge 0 ] 2>/dev/null
} }
ss_status_mark_failure 'artifact_download_failed' ss_status_mark_failure 'artifact_download_failed'
ss_spec_assert_eq "$(grep -c "$(printf 'batch\tset_many')" "$CALLS")" '1'
grep -F "$(printf 'set\tstatus\terror')" "$CALLS" >/dev/null grep -F "$(printf 'set\tstatus\terror')" "$CALLS" >/dev/null
grep -F "$(printf 'set\tlast_failure\t1000')" "$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_result\terror')" "$CALLS" >/dev/null
@@ -405,6 +418,7 @@ ss_case_status_state() (
! grep -F "$(printf 'set\tnext_refresh_at')" "$CALLS" >/dev/null ! grep -F "$(printf 'set\tnext_refresh_at')" "$CALLS" >/dev/null
: >"$CALLS" : >"$CALLS"
ss_status_reset_health_fields ss_status_reset_health_fields
ss_spec_assert_eq "$(grep -c "$(printf 'batch\tset_many')" "$CALLS")" '1'
for key in health_dnsmasq_binary health_dns_runtime health_artifact_download dnsmasq_min_version; do 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 grep -F "$(printf 'set\t%s\t' "$key")" "$CALLS" >/dev/null
done done
@@ -467,8 +481,45 @@ ss_case_status_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/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/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/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_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" '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' ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'No blocklist replacement has happened'
)
# The legacy refresh reset used one ucode process + one locked JSON rewrite
# per key. Grouping related updates keeps the same values with fewer writes.
ss_case_status_batch_writes() (
set -eu
TMP_DIR="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
RUNNING_STATUS_FILE="$TMP_DIR/status.json"
# shellcheck disable=SC1091
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh"
CALLS="$TMP_DIR/calls"
: >"$CALLS"
ss_status_apply() {
printf '%s\n' "$1" >>"$CALLS"
return 0
}
ss_now() { printf '%s\n' 1000; }
is_valid_integer() {
case "$1" in '' | *[!0-9]*) return 1 ;; esac
return 0
}
ss_status_reset
ss_spec_assert_eq "$(wc -l <"$CALLS" | tr -d '[:space:]')" '6'
ss_spec_assert_eq "$(grep -c '^set_many$' "$CALLS")" '4'
ss_spec_assert_eq "$(grep -c '^reset$' "$CALLS")" '1'
ss_spec_assert_eq "$(grep -c '^clear_messages$' "$CALLS")" '1'
: >"$CALLS"
ss_status_mark_success 300
ss_spec_assert_eq "$(wc -l <"$CALLS" | tr -d '[:space:]')" '2'
ss_spec_assert_file_line "$CALLS" 'set_many'
: >"$CALLS"
ss_status_mark_failure artifact_download_failed
ss_spec_assert_eq "$(wc -l <"$CALLS" | tr -d '[:space:]')" '2'
ss_spec_assert_file_line "$CALLS" 'add_error'
) )
+33 -36
View File
@@ -138,6 +138,7 @@ ss_case_refresh_state_machine() (
ss_spec_assert_file_line "$CALLS" 'restore_and_restart' ss_spec_assert_file_line "$CALLS" 'restore_and_restart'
else else
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null ! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
! grep -F 'dnsmasq_restart ' "$CALLS" >/dev/null
fi fi
} }
@@ -157,7 +158,10 @@ ss_case_refresh_state_machine() (
ss_spec_assert_file_line "$CALLS" 'lock_close' ss_spec_assert_file_line "$CALLS" 'lock_close'
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null ! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
assert_refresh_failure merge merge_failed 1 assert_refresh_failure merge merge_failed 0
assert_refresh_failure resolve artifact_resolve_failed 0
assert_refresh_failure download artifact_download_failed 0
assert_refresh_failure local_rules allowlist_build_failed 0
assert_refresh_failure install install_failed 1 assert_refresh_failure install install_failed 1
assert_refresh_failure restart dnsmasq_restart_failed 1 assert_refresh_failure restart dnsmasq_restart_failed 1
assert_refresh_failure runtime dns_runtime_check_failed 1 assert_refresh_failure runtime dns_runtime_check_failed 1
@@ -313,7 +317,12 @@ ss_case_local_apply_state_machine() (
verify) code='local_blocklist_verification_failed' ;; verify) code='local_blocklist_verification_failed' ;;
esac esac
ss_spec_assert_file_line "$CALLS" "local_failure $code" ss_spec_assert_file_line "$CALLS" "local_failure $code"
ss_spec_assert_file_line "$CALLS" 'restore_and_restart' if [ "$scenario" = 'merge' ]; then
! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null
! grep -Fx 'dnsmasq_restart' "$CALLS" >/dev/null
else
ss_spec_assert_file_line "$CALLS" 'restore_and_restart'
fi
ss_spec_assert_file_line "$CALLS" 'lock_close' ss_spec_assert_file_line "$CALLS" 'lock_close'
done done
@@ -333,54 +342,42 @@ ss_case_refresh_locking() (
set -eu set -eu
TMP="$(ss_spec_tmpdir)" TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM trap 'rm -rf "$TMP"' EXIT HUP INT TERM
RUNNING_STATUS_FILE="$TMP/status.json"
SS_REFRESH_LOCK="$TMP/safeshield-refresh.lock" SS_REFRESH_LOCK="$TMP/safeshield-refresh.lock"
SS_REFRESH_LOCK_FD=9 # shellcheck disable=SC1090
MOCK_FLOCK_DIR="$TMP/mock-flock.lock" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
# shellcheck disable=SC1090
# 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" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh"
is_valid_integer() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac } is_valid_integer() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac }
ss_should_stop() { return 1; } ss_should_stop() { return 1; }
mkdir "$MOCK_FLOCK_DIR" ss_refresh_lock_open
ss_spec_assert_eq "$(cat "${SS_REFRESH_LOCK}.d/pid")" "$$"
# A separate shell must not acquire or release our live lock.
! sh -c '. "$1"; ss_lock_try "$2"' sh "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" "${SS_REFRESH_LOCK}.d"
ss_refresh_lock_close
ss_spec_assert_not_exists "${SS_REFRESH_LOCK}.d"
# Simulate an owner which crashed after writing pid.
mkdir "${SS_REFRESH_LOCK}.d"
printf '99999999\n' >"${SS_REFRESH_LOCK}.d/pid"
ss_refresh_lock_open
ss_refresh_lock_close
# A freshly-created empty lock must not be stolen while the owner writes pid.
mkdir "${SS_REFRESH_LOCK}.d"
! ss_refresh_lock_open ! ss_refresh_lock_open
! ss_refresh_lock_open_wait 0 ! ss_refresh_lock_open_wait 0
rmdir "$MOCK_FLOCK_DIR" rmdir "${SS_REFRESH_LOCK}.d"
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; } ss_should_stop() { return 0; }
mkdir "${SS_REFRESH_LOCK}.d"
printf '%s\n' "$$" >"${SS_REFRESH_LOCK}.d/pid"
set +e set +e
ss_refresh_lock_open_wait 10 ss_refresh_lock_open_wait 10
rc=$? rc=$?
set -e set -e
rmdir "$MOCK_FLOCK_DIR"
ss_spec_assert_eq "$rc" '130' ss_spec_assert_eq "$rc" '130'
ss_refresh_lock_close
) )
ss_case_service_lifecycle() ( ss_case_service_lifecycle() (
+114
View File
@@ -0,0 +1,114 @@
#!/bin/sh
# shellcheck shell=sh
ss_case_lock_owner_and_contention() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
# shellcheck disable=SC1090
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
LOCK="$TMP/collector.lock"
ss_lock_try "$LOCK"
[ "$(cat "$LOCK/pid")" = "$$" ]
# Different process may not acquire the lock or remove its owner.
! sh -c '. "$1"; ss_lock_try "$2"' sh "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" "$LOCK"
! sh -c '. "$1"; ss_lock_release "$2"' sh "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" "$LOCK"
[ "$(cat "$LOCK/pid")" = "$$" ]
ss_lock_release "$LOCK"
[ ! -e "$LOCK" ]
# Stale pid may be reclaimed by exactly one contender.
mkdir "$LOCK"
printf '99999999\n' >"$LOCK/pid"
ss_lock_try "$LOCK"
[ "$(cat "$LOCK/pid")" = "$$" ]
ss_lock_release "$LOCK"
# Do not seize a newly created lock before its pid has been populated.
mkdir "$LOCK"
! ss_lock_try "$LOCK"
[ -d "$LOCK" ]
rmdir "$LOCK"
# An abandoned pid-less directory (including a crashed recovery guard)
# becomes reclaimable after its initialization grace period.
mkdir "$LOCK" "${LOCK}.reclaim"
touch -t 202001010000 "$LOCK" "${LOCK}.reclaim"
ss_lock_try "$LOCK"
ss_lock_release "$LOCK"
)
ss_case_lock_parallel() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
LOCK="$TMP/write.lock"
RESULT="$TMP/results"
for id in 1 2 3 4 5 6 7 8; do
sh -c '
. "$1"
ss_lock_wait "$2" 15 || exit 1
printf "%s\n" "$3" >>"$4"
sleep 0.05
ss_lock_release "$2"
' sh "$LIB" "$LOCK" "$id" "$RESULT" &
done
wait
[ "$(wc -l <"$RESULT")" -eq 8 ]
[ ! -e "$LOCK" ]
[ ! -e "${LOCK}.reclaim" ]
)
ss_case_status_write_serialization() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
STATUS="$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh"
# Test the real shell writer with a slow stand-in for the ucode serializer.
# A serializer must observe all previously committed updates under the same lock.
cat >"$TMP/writer.sh" <<'EOF_WRITER'
#!/bin/sh
. "$1"
. "$2"
RUNNING_STATUS_FILE="$3"
RUNNING_STATUS_LOCK="$4"
json_lock_open() { ss_lock_wait "$RUNNING_STATUS_LOCK" 10; }
json_lock_close() { ss_lock_release "$RUNNING_STATUS_LOCK"; }
ss_status_exec() {
key="$2"
[ -f "$RUNNING_STATUS_FILE" ] && cat "$RUNNING_STATUS_FILE"
sleep 0.05
printf '%s\n' "$key"
}
ss_status_apply set "$5" unused
EOF_WRITER
for n in 1 2 3 4 5 6 7 8; do
sh "$TMP/writer.sh" "$LIB" "$STATUS" "$TMP/status.json" "$TMP/status.lock" "$n" &
done
wait
[ "$(wc -l <"$TMP/status.json")" -eq 8 ]
[ ! -e "$TMP/status.lock" ]
[ ! -e "$TMP/status.json.tmp" ]
)
ss_case_status_write_failure() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
# shellcheck disable=SC1090
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh"
# shellcheck disable=SC1090
. "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh"
RUNNING_STATUS_LOCK="$TMP/status.lock"
RUNNING_STATUS_FILE="$TMP/status.json"
json_lock_open() { ss_lock_wait "$RUNNING_STATUS_LOCK" 10; }
json_lock_close() { ss_lock_release "$RUNNING_STATUS_LOCK"; }
printf 'original\n' >"$RUNNING_STATUS_FILE"
ss_status_exec() {
printf 'partial\n'
return 1
}
! ss_status_apply set key value
[ "$(cat "$RUNNING_STATUS_FILE")" = 'original' ]
[ ! -e "${RUNNING_STATUS_FILE}.tmp.$$" ]
[ ! -e "$RUNNING_STATUS_LOCK" ]
)
+2
View File
@@ -115,6 +115,7 @@ EOF_IP
PATH="$TMP/bin:$PATH" \ PATH="$TMP/bin:$PATH" \
SS_STATSD_FUNCTIONS_LIB="$TMP/functions.sh" \ SS_STATSD_FUNCTIONS_LIB="$TMP/functions.sh" \
SS_STATSD_CORE_LIB="$TMP/core.sh" \ SS_STATSD_CORE_LIB="$TMP/core.sh" \
SS_STATSD_LOCK_LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" \
SS_STATSD_AWK_DIR="$SS_SPEC_ROOT/files/usr/lib/safeshield/statistics" \ SS_STATSD_AWK_DIR="$SS_SPEC_ROOT/files/usr/lib/safeshield/statistics" \
SS_STATSD_POLL_COMMAND="$TMP/bin/stats-poll" \ SS_STATSD_POLL_COMMAND="$TMP/bin/stats-poll" \
SS_STATSD_GENERATION_ID='runtime-generation' \ SS_STATSD_GENERATION_ID='runtime-generation' \
@@ -580,6 +581,7 @@ EOF_CORE
write_payload "$SS_STATISTICS_UPLOAD_JSON_FILE" 7 write_payload "$SS_STATISTICS_UPLOAD_JSON_FILE" 7
upload_pending_create upload_pending_create
ss_spec_assert_file_contains "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" "$(printf 'recent\tgeneration-upload\t7')" ss_spec_assert_file_contains "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" "$(printf 'recent\tgeneration-upload\t7')"
ss_should_terminate=0
MOCK_HTTP_MODE='stale' MOCK_HTTP_MODE='stale'
export MOCK_HTTP_MODE export MOCK_HTTP_MODE
upload_send_pending upload_send_pending
@@ -1272,3 +1272,37 @@ DATA
! grep -F '"bucket_start":1787950800' "$UPLOAD" >/dev/null ! grep -F '"bucket_start":1787950800' "$UPLOAD" >/dev/null
ss_spec_assert_eq "$(sed -n 's/.*"snapshot_seq":\([0-9][0-9]*\).*/\1/p' "$UPLOAD")" "$(sed -n 's/.*"snapshot_seq":\([0-9][0-9]*\).*/\1/p' "$JSON")" ss_spec_assert_eq "$(sed -n 's/.*"snapshot_seq":\([0-9][0-9]*\).*/\1/p' "$UPLOAD")" "$(sed -n 's/.*"snapshot_seq":\([0-9][0-9]*\).*/\1/p' "$JSON")"
) )
# Verifies that caching the retention sweep never leaves expired history in
# the local JSON, the persisted state, or the short cloud upload projection.
ss_case_statistics_retention_cache() (
set -eu
TMP="$(ss_spec_tmpdir)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
STATE="$TMP/state.tsv"
JSON="$TMP/statistics.json"
UPLOAD="$TMP/upload.json"
INPUT="$TMP/input.log"
LEASES="$TMP/dhcp.leases"
: >"$LEASES"
cat >"$INPUT" <<'DATA'
daemon.info dnsmasq[1]: query[A] one.example from 192.168.1.1
daemon.info dnsmasq[1]: query[A] two.example from 192.168.1.1
daemon.info dnsmasq[1]: query[A] three.example from 192.168.1.1
DATA
ss_statistics_awk \
-v state_file="$STATE" -v json_file="$JSON" \
-v upload_json_file="$UPLOAD" -v lease_file="$LEASES" \
-v snapshot_interval=1 -v retention_hours=2 \
-v upload_window_hours=1 -v provisional_retention_hours=2 \
-v generation_seed='retention-test' \
-v fixed_now=1787950800 -v fixed_step=3600 <"$INPUT"
# The final event is in the third hour. The two-hour retention window
# contains exactly the second and third events.
ss_spec_assert_file_contains "$JSON" '"totals":{"queries":2,"blocked":0}'
ss_spec_assert_file_contains "$JSON" '"retention_hours":2'
ss_spec_assert_file_contains "$UPLOAD" '"totals":{"queries":1,"blocked":0}'
! grep -F 'bucket' "$STATE" | grep -F '1787950800' >/dev/null
ss_spec_assert_file_contains "$STATE" "$(printf 'bucket\t1787958000\t1\t0')"
)
+2
View File
@@ -12,6 +12,8 @@ ss_case_tooling() (
[ -f "$SS_SPEC_ROOT/spec/statistics_spec.sh" ] [ -f "$SS_SPEC_ROOT/spec/statistics_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/ucode_spec.sh" ] [ -f "$SS_SPEC_ROOT/spec/ucode_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/tooling_spec.sh" ] [ -f "$SS_SPEC_ROOT/spec/tooling_spec.sh" ]
[ -f "$SS_SPEC_ROOT/spec/locking_spec.sh" ]
[ -f "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" ]
[ -f "$SS_SPEC_ROOT/scripts/lint.sh" ] [ -f "$SS_SPEC_ROOT/scripts/lint.sh" ]
[ -f "$SS_SPEC_ROOT/.github/workflows/lint-shell.yml" ] [ -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" 'entry: sh scripts/lint.sh'
+15
View File
@@ -48,6 +48,21 @@ ss_case_ucode() (
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" set stage running)" out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" set stage running)"
printf '%s\n' "$out" >"$status_file" printf '%s\n' "$out" >"$status_file"
printf '%s\n' "$out" | grep -F '"stage":"running"' >/dev/null printf '%s\n' "$out" | grep -F '"stage":"running"' >/dev/null
# One invocation must update every pair without dropping existing status.
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" set_many status ready stage 'done' count 0 optional '')"
printf '%s\n' "$out" >"$status_file"
printf '%s\n' "$out" | grep -F '"status":"ready"' >/dev/null
printf '%s\n' "$out" | grep -F '"stage":"done"' >/dev/null
printf '%s\n' "$out" | grep -F '"count":"0"' >/dev/null
printf '%s\n' "$out" | grep -F '"optional":""' >/dev/null
printf '%s\n' "$out" | grep -F '"stage":"running"' >/dev/null && return 1
# Reject malformed pair lists, never serialize partial changes.
if "$UCODE_BIN" "$STATUS_STORE" "$status_file" set_many incomplete >/dev/null 2>&1; then
return 1
fi
if "$UCODE_BIN" "$STATUS_STORE" "$status_file" set_many '' value >/dev/null 2>&1; then
return 1
fi
out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" add_warning dnsmasq_warning)" out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" add_warning dnsmasq_warning)"
printf '%s\n' "$out" >"$status_file" printf '%s\n' "$out" >"$status_file"
printf '%s\n' "$out" | grep -F '"code":"dnsmasq_warning"' >/dev/null printf '%s\n' "$out" | grep -F '"code":"dnsmasq_warning"' >/dev/null
+12
View File
@@ -28,3 +28,15 @@ assert(result.devices[0].identified == true, 'device identified flag is normaliz
assert(result.devices[0].queries == 50 && result.devices[0].blocked == 5, 'device counters are normalized'); assert(result.devices[0].queries == 50 && result.devices[0].blocked == 5, 'device counters are normalized');
print('ucode statistics tests: ok\n'); print('ucode statistics tests: ok\n');
// Summary is deliberately opt-in. It must not duplicate large per-device
// arrays while preserving the health/totals metadata used by lightweight UIs.
let summary = statistics.summary();
assert(summary.schema.name == result.schema.name && summary.schema.version == result.schema.version, 'summary preserves schema');
assert(summary.snapshot_seq == result.snapshot_seq && summary.generation_id == result.generation_id, 'summary preserves freshness');
assert(summary.enabled == result.enabled && summary.collector_running == result.collector_running, 'summary preserves service status');
assert(summary.source.backend == result.source.backend && summary.source.tracked_clients == result.source.tracked_clients, 'summary preserves source health');
assert(summary.totals.queries == result.totals.queries && summary.totals.blocked == result.totals.blocked, 'summary preserves totals');
assert(summary.device_limit == result.device_limit && summary.devices_truncated == result.devices_truncated, 'summary preserves device capacity metadata');
assert(summary.hourly == null && summary.devices == null, 'summary omits detailed arrays');
assert(length(result.hourly) == 2 && length(result.devices) == 1, 'full statistics remains unchanged after summary');