From db798bdf5e6457c28d39b2b9342cbe2ac7ac4134 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Nov 2026 12:48:14 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Sync=202026-11-02=2012:48:14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apfree-wifidog/Makefile | 4 +- dockerd/Makefile | 18 +- luci-app-passwall/Makefile | 2 +- .../view/passwall/app_update/app_version.htm | 8 +- luci-app-passwall2/Makefile | 2 +- .../view/passwall2/app_update/app_version.htm | 17 +- luci-app-trafficctl/Makefile | 4 +- .../etc/hotplug.d/iface/99-trafficctl-shapes | 20 +- .../root/usr/local/bin/trafficctl-history.sh | 74 ++- luci-app-vless-core/Makefile | 11 + .../luci-static/resources/view/vless-core.js | 76 +++ .../luci/menu.d/luci-app-vless-core.json | 13 + .../share/rpcd/acl.d/luci-app-vless-core.json | 12 + .../resources/view/wwand/modems.js | 24 +- .../luci-static/resources/wwand/modemopts.js | 7 + luci-app-wwand/po/templates/wwand.pot | 584 +++++++++--------- luci-proto-hev2socks/Makefile | 14 + .../luci-static/resources/protocol/h2s.js | 90 +++ .../root/lib/netifd/proto/h2s.sh | 228 +++++++ safeshield/CHANGELOG.md | 43 ++ safeshield/Makefile | 5 +- safeshield/README.md | 13 + safeshield/files/etc/init.d/safeshield | 2 +- .../files/usr/lib/safeshield/blocklist.sh | 122 +++- safeshield/files/usr/lib/safeshield/core.sh | 12 +- .../files/usr/lib/safeshield/locking.sh | 119 ++++ .../lib/safeshield/statistics/00-common.awk | 11 + .../lib/safeshield/statistics/20-identity.awk | 2 + .../lib/safeshield/statistics/50-output.awk | 15 +- .../usr/lib/safeshield/statistics/90-main.awk | 5 + .../files/usr/lib/safeshield/status-store.uc | 13 + safeshield/files/usr/lib/safeshield/status.sh | 208 +++---- safeshield/files/usr/lib/safeshield/utils.sh | 32 +- .../files/usr/libexec/safeshield-refreshd | 20 +- .../files/usr/libexec/safeshield-statsd | 28 +- .../usr/share/rpcd/acl.d/safeshield.json | 1 + .../files/usr/share/rpcd/ucode/safeshield.uc | 4 + .../share/rpcd/ucode/safeshield/statistics.uc | 29 +- safeshield/spec/blocklist_spec.sh | 21 + safeshield/spec/core_spec.sh | 7 + safeshield/spec/lifecycle_spec.sh | 2 +- safeshield/spec/locking_spec.sh | 32 + safeshield/spec/spec_helper.sh | 2 + safeshield/spec/statistics_spec.sh | 7 + safeshield/spec/support/blocklist_cases.sh | 197 ++++++ safeshield/spec/support/core_cases.sh | 55 +- safeshield/spec/support/lifecycle_cases.sh | 69 +-- safeshield/spec/support/locking_cases.sh | 114 ++++ safeshield/spec/support/runtime_cases.sh | 2 + safeshield/spec/support/statistics_cases.sh | 34 + safeshield/spec/support/tooling_cases.sh | 2 + safeshield/spec/support/ucode_cases.sh | 15 + safeshield/tests/ucode/test_statistics.uc | 12 + 53 files changed, 1895 insertions(+), 568 deletions(-) create mode 100644 luci-app-vless-core/Makefile create mode 100644 luci-app-vless-core/htdocs/luci-static/resources/view/vless-core.js create mode 100644 luci-app-vless-core/root/usr/share/luci/menu.d/luci-app-vless-core.json create mode 100644 luci-app-vless-core/root/usr/share/rpcd/acl.d/luci-app-vless-core.json create mode 100644 luci-proto-hev2socks/Makefile create mode 100644 luci-proto-hev2socks/htdocs/luci-static/resources/protocol/h2s.js create mode 100755 luci-proto-hev2socks/root/lib/netifd/proto/h2s.sh create mode 100644 safeshield/files/usr/lib/safeshield/locking.sh create mode 100755 safeshield/spec/locking_spec.sh create mode 100755 safeshield/spec/support/locking_cases.sh diff --git a/apfree-wifidog/Makefile b/apfree-wifidog/Makefile index 5b049f98b..f23c5c9d9 100644 --- a/apfree-wifidog/Makefile +++ b/apfree-wifidog/Makefile @@ -8,12 +8,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=apfree-wifidog -PKG_RELEASE:=9 +PKG_RELEASE:=10 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/liudf0716/apfree-wifidog.git PKG_SOURCE_DATE:=2026-10-25 -PKG_SOURCE_VERSION:=4075182ffedaf19b36926dc2f068fe413901aba3 +PKG_SOURCE_VERSION:=9997baf26c737102940dd3a44e9d95c84dd57dab PKG_MIRROR_HASH:=skip PKG_MAINTAINER:=Dengfeng Liu diff --git a/dockerd/Makefile b/dockerd/Makefile index fc904c71e..3b99365f5 100644 --- a/dockerd/Makefile +++ b/dockerd/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dockerd PKG_VERSION:=29.6.1 -PKG_RELEASE:=10 +PKG_RELEASE:=11 PKG_LICENSE:=Apache-2.0 PKG_LICENSE_FILES:=LICENSE PKG_CPE_ID:=cpe:/a:mobyproject:moby @@ -138,10 +138,18 @@ define Package/dockerd/install $(INSTALL_DIR) $(1)/etc/config $(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/sysctl.d - $(INSTALL_DATA) ./files/etc/sysctl.d/sysctl-br-netfilter-ip.conf \ - $(1)/etc/sysctl.d/12-br-netfilter-ip.conf + $(INSTALL_DIR) $(1)/etc/hotplug.d/net + $(INSTALL_DATA) ./files/etc/hotplug.d/net/br-netfilter \ + $(1)/etc/hotplug.d/net/10-docker-br-netfilter + + $(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 define Package/dockerd/postinst diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index c3d8ae333..c56a4c6cb 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.10.29 -PKG_RELEASE:=362 +PKG_RELEASE:=363 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm b/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm index fc4a3f853..1ff279a4b 100644 --- a/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm +++ b/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm @@ -149,6 +149,13 @@ local com = self.com function doUpdate(btn, app) { 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; else componentUpdates++; setButtonDisabled(btn, true); @@ -157,7 +164,6 @@ local com = self.com addPageNotice(); var appUpdateUrl = '<%=api.url("update_")%>' + app; - var appInfo = appInfoList[app]; var downloadId = Date.now().toString(36) + Math.random().toString(36).substring(2, 8); var totalSize = appInfo && appInfo.data ? Number(appInfo.data.size) : 0; if (app === appname) totalSize += Number(appInfo.i18n.size) || 0; diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index 482967ba2..54f8e9f70 100644 --- a/luci-app-passwall2/Makefile +++ b/luci-app-passwall2/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall2 PKG_VERSION:=26.10.29 -PKG_RELEASE:=163 +PKG_RELEASE:=164 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm b/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm index fc7433395..65d86fcaa 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm @@ -149,6 +149,20 @@ local com = self.com function doUpdate(btn, app) { 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; else componentUpdates++; setButtonDisabled(btn, true); @@ -157,10 +171,7 @@ local com = self.com addPageNotice(); var appUpdateUrl = '<%=api.url("update_")%>' + app; - var appInfo = appInfoList[app]; 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; for (var i = 0; i < downloadList.length; i++) totalSize += Number(downloadList[i].size) || 0; if (!isFinite(totalSize) || totalSize <= 0) { diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index 1be785c2b..47c5bd140 100644 --- a/luci-app-trafficctl/Makefile +++ b/luci-app-trafficctl/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-trafficctl -PKG_VERSION:=1.25.2 -PKG_RELEASE:=18 +PKG_VERSION:=1.25.4 +PKG_RELEASE:=19 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Denis Iusupov diff --git a/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes b/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes index f7a601f58..d05d61b40 100644 --- a/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes +++ b/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes @@ -41,9 +41,23 @@ fi # Restore blocks and ratelimits (if persist enabled) if [ -f "$RULES_FILE" ] && tctl_persist_enabled; then - jsonfilter -i "$RULES_FILE" -e '@[*]' 2>/dev/null | while read -r _; do true; done - - for idx in $(seq 0 99); do + # 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 + # 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) [ -z "$type" ] && break ip=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].ip" 2>/dev/null) diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh index d90774e89..22b57c1ab 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh @@ -94,6 +94,10 @@ STAMP="/tmp/trafficctl_history.stamp" LOCKD="/tmp/trafficctl_history.lock.d" TOTALS_STATE="/tmp/trafficctl_totals.state" 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: # 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 # routine — so a flap costs one sampling interval instead of corrupting the # month. -hist_if_flush() { - local dir pid file tmp now sample rc=0 +# Fold the growth since the last tick into the tmpfs accumulator and advance +# 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 dir=$(hist_dir) pid=$(hist_period_id) file=$(hist_iffile "$pid") + acc="$IFACC.$pid" now=$(date +%s) sample="/tmp/.trafficctl_history.ifsample.$$" - tmp="$dir/.history-if.$pid.$$" + tmp="$acc.$$" + [ -f "$acc" ] && file="$acc" # shellcheck disable=SC2064 trap "rm -f '$sample' '$tmp'" EXIT INT TERM @@ -694,12 +710,24 @@ hist_if_flush() { lrx = num(line, "rx_bytes"); if (lrx < 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 - if (seen[d]) { - drx = 0; dtx = 0 - if (lrx >= crx[d]) drx = lrx - crx[d] - 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)) { order[++norder] = d have[d] = 1 @@ -736,11 +764,32 @@ hist_if_flush() { }' /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 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() { local dir from to files f p dir=$(hist_dir) @@ -955,6 +1004,9 @@ hist_keeper() { while :; do if hist_enabled; then /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) interval=$(hist_interval) # 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. hist_enabled || { printf '{"ok":false,"reason":"disabled"}\n'; exit 0; } /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)" ;; flush) hist_flush ;; diff --git a/luci-app-vless-core/Makefile b/luci-app-vless-core/Makefile new file mode 100644 index 000000000..3fc753241 --- /dev/null +++ b/luci-app-vless-core/Makefile @@ -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 diff --git a/luci-app-vless-core/htdocs/luci-static/resources/view/vless-core.js b/luci-app-vless-core/htdocs/luci-static/resources/view/vless-core.js new file mode 100644 index 000000000..eac78ffaa --- /dev/null +++ b/luci-app-vless-core/htdocs/luci-static/resources/view/vless-core.js @@ -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.') + '
' + + '' + _('Status') + ': ' + + (isRunning(res) + ? '' + _('running') + '' + : '' + _('stopped') + '')); + + 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'), + _('vless://, vmess://, trojan://, ' + + 'ss:// or socks5:// 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: ENABLED;DEFAULT;LAN;action,type,value;...
' + + 'DEFAULT and action: proxy, direct, block. ' + + 'Type: domain, suffix, cidr, port.
' + + 'Example: 1;proxy;1;direct,suffix,ru;block,port,25')); + o.optional = true; + + o = s.option(form.ListValue, 'tls_mode', _('Certificate verification'), + _('strict: verified TLS only (needs ca-bundle for security=tls). ' + + 'insecure: never verify. ' + + 'tofu: pin the certificate on first connection.')); + o.value('strict', _('strict')); + o.value('insecure', _('insecure')); + o.value('tofu', _('tofu')); + o.default = 'strict'; + + return m.render(); + } +}); diff --git a/luci-app-vless-core/root/usr/share/luci/menu.d/luci-app-vless-core.json b/luci-app-vless-core/root/usr/share/luci/menu.d/luci-app-vless-core.json new file mode 100644 index 000000000..ac5a14000 --- /dev/null +++ b/luci-app-vless-core/root/usr/share/luci/menu.d/luci-app-vless-core.json @@ -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 } + } + } +} diff --git a/luci-app-vless-core/root/usr/share/rpcd/acl.d/luci-app-vless-core.json b/luci-app-vless-core/root/usr/share/rpcd/acl.d/luci-app-vless-core.json new file mode 100644 index 000000000..52627f64e --- /dev/null +++ b/luci-app-vless-core/root/usr/share/rpcd/acl.d/luci-app-vless-core.json @@ -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" ] + } + } +} diff --git a/luci-app-wwand/htdocs/luci-static/resources/view/wwand/modems.js b/luci-app-wwand/htdocs/luci-static/resources/view/wwand/modems.js index 70c1b9e92..d01eaf6f4 100644 --- a/luci-app-wwand/htdocs/luci-static/resources/view/wwand/modems.js +++ b/luci-app-wwand/htdocs/luci-static/resources/view/wwand/modems.js @@ -246,13 +246,23 @@ return view.extend({ 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 (field report with a screenshot, RUTM11). Everything from Tools on - now lives in one dropdown (below), which leaves four. The wrapping - flex row stays as the backstop for a narrow column: the buttons - keep their theme styling and stay right-aligned, they just fold - onto a second line rather than overflowing. */ - box.style.display = 'flex'; - box.style.flexWrap = 'wrap'; - box.style.justifyContent = 'flex-end'; + now lives in one dropdown (below), which leaves four, laid out + as a fixed two-by-two grid. + + Not a wrapping flex row: a button that grows reflows one of + those, and a button DOES grow while its action runs: Config is + a createHandlerFn button (form.js:3238), which marks it + `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'; var jump = function(label, title, url) { diff --git a/luci-app-wwand/htdocs/luci-static/resources/wwand/modemopts.js b/luci-app-wwand/htdocs/luci-static/resources/wwand/modemopts.js index 54baff7f8..46a00a60e 100644 --- a/luci-app-wwand/htdocs/luci-static/resources/wwand/modemopts.js +++ b/luci-app-wwand/htdocs/luci-static/resources/wwand/modemopts.js @@ -548,6 +548,13 @@ return baseclass.extend({ before it gets that far */ 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. nr5g,lte. 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,umts', 'NR5G, LTE, UMTS'); o.value('lte,nr5g', 'LTE, NR5G'); diff --git a/luci-app-wwand/po/templates/wwand.pot b/luci-app-wwand/po/templates/wwand.pot index 75a67e27e..abba17881 100644 --- a/luci-app-wwand/po/templates/wwand.pot +++ b/luci-app-wwand/po/templates/wwand.pot @@ -1,13 +1,13 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: htdocs/luci-static/resources/view/wwand/settings.js:711 +#: htdocs/luci-static/resources/view/wwand/settings.js:713 msgid "" "\"%s\" is already a \"%s\" section in /etc/config/network — refusing to " "overwrite it. Pick a different name." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:564 +#: htdocs/luci-static/resources/wwand/modemopts.js:574 msgid "\"%s\" is listed twice" msgstr "" @@ -106,7 +106,7 @@ msgstr "" msgid "%s, slot %d" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1031 +#: htdocs/luci-static/resources/view/wwand/settings.js:1033 msgid "(incomplete)" msgstr "" @@ -118,7 +118,7 @@ msgstr "" msgid "(last attempt)" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:586 +#: htdocs/luci-static/resources/wwand/modemopts.js:596 #: htdocs/luci-static/resources/wwand/simlist.js:232 msgid "(none)" msgstr "" @@ -131,7 +131,7 @@ msgstr "" msgid "(runtime only)" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:235 +#: htdocs/luci-static/resources/view/wwand/settings.js:237 msgid "(slot without a name)" msgstr "" @@ -187,8 +187,8 @@ msgstr "" msgid "4 — embedded" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:912 -#: htdocs/luci-static/resources/wwand/modemopts.js:608 +#: htdocs/luci-static/resources/view/wwand/settings.js:914 +#: htdocs/luci-static/resources/wwand/modemopts.js:618 msgid "5G NR SA cell lock" msgstr "" @@ -196,20 +196,20 @@ msgstr "" msgid "5G NR neighbour cells" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1308 +#: htdocs/luci-static/resources/view/wwand/settings.js:1310 msgid "" "5G bands for non-standalone operation, where a 5G carrier rides on an LTE " "anchor. Which of the two lists applies depends on the network: the Status " "page names the mode the modem is actually in." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1306 +#: htdocs/luci-static/resources/view/wwand/settings.js:1308 msgid "" "5G bands for standalone operation, where the modem talks 5G only with no LTE " "anchor." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1304 +#: htdocs/luci-static/resources/view/wwand/settings.js:1306 msgid "" "5G bands. This modem has one 5G band list for standalone and non-standalone " "operation alike." @@ -225,7 +225,7 @@ msgid "" "(24 GHz and above)" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:388 +#: htdocs/luci-static/resources/view/wwand/modems.js:398 msgid "" "A SIM override for ICCID %s already exists (section %s) — edit it in the " "SIMs list below." @@ -235,7 +235,7 @@ msgstr "" msgid "A datapath name: lowercase letters, digits, underscore and hyphen." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:535 +#: htdocs/luci-static/resources/view/wwand/settings.js:537 msgid "" "A different configuration is selected and waiting for a modem reset — the " "radio is still running the previous one." @@ -253,11 +253,11 @@ msgid "" "write access." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:913 +#: htdocs/luci-static/resources/view/wwand/settings.js:915 msgid "A single 5G SA cell as \"pci:arfcn:scs:band\"." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:782 +#: htdocs/luci-static/resources/view/wwand/modems.js:792 #: htdocs/luci-static/resources/wwand/simlist.js:186 msgid "APN" msgstr "" @@ -283,12 +283,12 @@ msgstr "" msgid "AT control TTY" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:396 -#: htdocs/luci-static/resources/view/wwand/settings.js:602 +#: htdocs/luci-static/resources/view/wwand/settings.js:398 +#: htdocs/luci-static/resources/view/wwand/settings.js:604 msgid "Access technologies" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:528 +#: htdocs/luci-static/resources/view/wwand/modems.js:538 msgid "Actions" msgstr "" @@ -306,7 +306,7 @@ msgid "" "the interface page." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:728 +#: htdocs/luci-static/resources/view/wwand/settings.js:730 msgid "Add entry" msgstr "" @@ -337,7 +337,7 @@ msgstr "" msgid "All" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:740 +#: htdocs/luci-static/resources/wwand/modemopts.js:750 msgid "All sentences" msgstr "" @@ -353,17 +353,17 @@ msgid "" "until the reason is gone, then the radio is switched on again by itself." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:827 +#: htdocs/luci-static/resources/view/wwand/settings.js:829 msgid "" "Applied, except the RAT priority: the modem accepted it and kept its own " "order. Its firmware does not take a priority this way." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:818 +#: htdocs/luci-static/resources/view/wwand/settings.js:820 msgid "Applied, except the band settings — the modem refused them (%s)." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1321 +#: htdocs/luci-static/resources/view/wwand/settings.js:1323 msgid "Apply" msgstr "" @@ -371,8 +371,8 @@ msgstr "" msgid "Asking the modem directly…" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:484 -#: htdocs/luci-static/resources/view/wwand/modems.js:519 +#: htdocs/luci-static/resources/view/wwand/modems.js:494 +#: htdocs/luci-static/resources/view/wwand/modems.js:529 msgid "Attach" msgstr "" @@ -388,7 +388,7 @@ msgstr "" msgid "Attach authentication" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:481 +#: htdocs/luci-static/resources/view/wwand/modems.js:491 msgid "Attach failed: %s." msgstr "" @@ -396,7 +396,7 @@ msgstr "" msgid "Attach password" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:470 +#: htdocs/luci-static/resources/view/wwand/modems.js:480 msgid "" "Attach the modem to the network again; its interfaces reconnect on their own." msgstr "" @@ -418,7 +418,7 @@ msgstr "" msgid "Authentication type" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:745 +#: htdocs/luci-static/resources/wwand/modemopts.js:755 msgid "Auto-correct modem config" msgstr "" @@ -452,7 +452,7 @@ msgstr "" msgid "Band power (RSSI)" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:513 +#: htdocs/luci-static/resources/view/wwand/modems.js:523 msgid "Band, operator, SIM, eSIM and SMS tools for this modem" msgstr "" @@ -468,7 +468,7 @@ msgstr "" msgid "Bind by USB serial" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:430 +#: htdocs/luci-static/resources/view/wwand/modems.js:440 msgid "Cancel" msgstr "" @@ -484,20 +484,20 @@ msgstr "" msgid "Carrier aggregation" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:532 +#: htdocs/luci-static/resources/view/wwand/settings.js:534 msgid "Carrier configuration" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:521 +#: htdocs/luci-static/resources/view/wwand/settings.js:523 msgid "Carrier configuration selected. It takes effect after a modem reset." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:848 -#: htdocs/luci-static/resources/view/wwand/settings.js:909 +#: htdocs/luci-static/resources/view/wwand/settings.js:850 +#: htdocs/luci-static/resources/view/wwand/settings.js:911 msgid "Cell lock" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:896 +#: htdocs/luci-static/resources/view/wwand/settings.js:898 msgid "Cell lock saved. Reconnect the interface to apply." msgstr "" @@ -523,7 +523,7 @@ msgstr "" msgid "Channel bandwidth of the carrier" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:963 +#: htdocs/luci-static/resources/view/wwand/settings.js:965 msgid "Choose a storage and click Load." msgstr "" @@ -531,7 +531,7 @@ msgstr "" msgid "Class" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:945 +#: htdocs/luci-static/resources/view/wwand/settings.js:947 msgid "Clear lock" msgstr "" @@ -539,7 +539,7 @@ msgstr "" msgid "Config" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:538 +#: htdocs/luci-static/resources/view/wwand/settings.js:540 msgid "Configuration" msgstr "" @@ -547,12 +547,12 @@ msgstr "" msgid "Configuration warnings" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:710 -#: htdocs/luci-static/resources/view/wwand/modems.js:714 +#: htdocs/luci-static/resources/view/wwand/modems.js:720 +#: htdocs/luci-static/resources/view/wwand/modems.js:724 msgid "Configure" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:769 +#: htdocs/luci-static/resources/view/wwand/settings.js:771 msgid "Configured list restored to the modem." msgstr "" @@ -578,7 +578,7 @@ msgstr "" msgid "Contacting operator…" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:662 +#: htdocs/luci-static/resources/view/wwand/modems.js:672 msgid "" "Control devices present on the system without a modem configuration. " "Configure stages a prefilled modem section — press Save & Apply afterwards, " @@ -596,15 +596,15 @@ msgid "" "(the USB standard) or NCM (AT commands with an ethernet-style data port)" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:712 +#: htdocs/luci-static/resources/view/wwand/modems.js:722 msgid "Could not configure modem: %s" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1128 +#: htdocs/luci-static/resources/view/wwand/settings.js:1130 msgid "Could not read messages: %s" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:654 +#: htdocs/luci-static/resources/view/wwand/settings.js:656 msgid "Could not read the %s list: %s" msgstr "" @@ -624,7 +624,7 @@ msgstr "" msgid "Create" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:379 +#: htdocs/luci-static/resources/view/wwand/modems.js:389 msgid "" "Create a per-ICCID SIM override for the inserted card (ICCID %s), pre-filled " "with the modem's current PIN and the interface APN/auth. Edit it in the SIMs " @@ -635,7 +635,7 @@ msgstr "" msgid "Create settings for this SIM card" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:392 +#: htdocs/luci-static/resources/view/wwand/modems.js:402 msgid "" "Created SIM override for ICCID %s — review it in the SIMs list below, then " "Save & Apply." @@ -673,7 +673,7 @@ msgstr "" msgid "Datapath counters (parent)" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:691 +#: htdocs/luci-static/resources/wwand/modemopts.js:701 msgid "Dead-bearer poll count" msgstr "" @@ -687,26 +687,26 @@ msgstr "" msgid "Delete" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1096 +#: htdocs/luci-static/resources/view/wwand/settings.js:1098 msgid "Delete %d selected" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1065 +#: htdocs/luci-static/resources/view/wwand/settings.js:1067 msgid "Delete %d selected message(s)?" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:633 +#: htdocs/luci-static/resources/view/wwand/modems.js:643 msgid "Delete modem \"%s\"?" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:631 +#: htdocs/luci-static/resources/view/wwand/modems.js:641 msgid "" "Delete modem \"%s\"? It is still used by: %s. Those interfaces lose their " "modem and their connection when you Save & Apply." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1085 -#: htdocs/luci-static/resources/view/wwand/settings.js:1097 +#: htdocs/luci-static/resources/view/wwand/settings.js:1087 +#: htdocs/luci-static/resources/view/wwand/settings.js:1099 msgid "Delete selected" msgstr "" @@ -714,7 +714,7 @@ msgstr "" msgid "Delete the override for SIM %s? Its PIN and APN settings are lost." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:753 +#: htdocs/luci-static/resources/view/wwand/settings.js:755 msgid "Delete the saved list \"%s\"?" msgstr "" @@ -722,34 +722,34 @@ msgstr "" msgid "Delete this SIM override? Its PIN and APN settings are lost." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1079 +#: htdocs/luci-static/resources/view/wwand/settings.js:1081 msgid "" "Deleted %d of %d storage slots; %d could not be deleted. A message spanning " "several slots may be partly left behind." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:484 -#: htdocs/luci-static/resources/view/wwand/modems.js:519 +#: htdocs/luci-static/resources/view/wwand/modems.js:494 +#: htdocs/luci-static/resources/view/wwand/modems.js:529 msgid "Detach" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:481 +#: htdocs/luci-static/resources/view/wwand/modems.js:491 msgid "Detach failed: %s." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:474 +#: htdocs/luci-static/resources/view/wwand/modems.js:484 msgid "" "Detach modem \"%s\" from the network? Its connections stop and stay down " "until you attach it again." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:661 +#: htdocs/luci-static/resources/view/wwand/modems.js:671 msgid "Detected modems (unconfigured)" msgstr "" #: htdocs/luci-static/resources/view/wwand/modems.js:191 -#: htdocs/luci-static/resources/view/wwand/modems.js:666 -#: htdocs/luci-static/resources/view/wwand/modems.js:781 +#: htdocs/luci-static/resources/view/wwand/modems.js:676 +#: htdocs/luci-static/resources/view/wwand/modems.js:791 msgid "Device" msgstr "" @@ -834,7 +834,7 @@ msgstr "" msgid "EPS attach" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:250 +#: htdocs/luci-static/resources/view/wwand/settings.js:252 msgid "Earlier" msgstr "" @@ -872,11 +872,11 @@ msgid "" "the SIM boot without a PIN." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:456 +#: htdocs/luci-static/resources/view/wwand/modems.js:466 msgid "Enter PIN" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:427 +#: htdocs/luci-static/resources/view/wwand/modems.js:437 msgid "Enter PUK — SIM %s" msgstr "" @@ -912,7 +912,7 @@ msgstr "" msgid "FCC unlock" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:641 +#: htdocs/luci-static/resources/wwand/modemopts.js:651 msgid "" "Failed attempts at which the recovery ladder cycles the radio, resets the " "modem and pulses the reset line / repowers it, e.g. \"2 6 12\". One or two " @@ -921,7 +921,7 @@ msgid "" "after a switch-off." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:833 +#: htdocs/luci-static/resources/view/wwand/settings.js:835 #: htdocs/luci-static/resources/wwand/esim.js:507 #: htdocs/luci-static/resources/wwand/netsel.js:51 msgid "Failed:" @@ -935,7 +935,7 @@ msgstr "" msgid "Firmware" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1392 +#: htdocs/luci-static/resources/view/wwand/settings.js:1394 msgid "Firmware protocol mode" msgstr "" @@ -953,19 +953,19 @@ msgstr "" msgid "Fix age" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:739 +#: htdocs/luci-static/resources/wwand/modemopts.js:749 msgid "Fix only (less CPU)" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:740 +#: htdocs/luci-static/resources/view/wwand/settings.js:742 msgid "Forbidden" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:597 +#: htdocs/luci-static/resources/view/wwand/settings.js:599 msgid "Forbidden list (FPLMN, EF 6F7B)" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:357 +#: htdocs/luci-static/resources/view/wwand/modems.js:367 msgid "" "Force a network re-registration (deregister + re-attach) so automatic " "selection re-scans. Use when the modem stays on a network you manually " @@ -986,11 +986,11 @@ msgstr "" msgid "GNSS" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:736 +#: htdocs/luci-static/resources/wwand/modemopts.js:746 msgid "GNSS NMEA output" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:719 +#: htdocs/luci-static/resources/wwand/modemopts.js:729 msgid "GNSS receiver" msgstr "" @@ -1004,13 +1004,13 @@ msgid "" "neither unit has to be converted in your head." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:339 +#: htdocs/luci-static/resources/view/wwand/modems.js:349 msgid "" "Hard-repower modem \"%s\" now? This cuts and restores the modem's power/" "reset line." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:336 +#: htdocs/luci-static/resources/view/wwand/modems.js:346 msgid "" "Hardware repower: pulse the reset GPIO (or power-cycle the modem) — recovers " "a hung or vanished modem. Needs a reset_gpio on multi-modem boxes." @@ -1020,7 +1020,7 @@ msgstr "" msgid "Hardware step" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:795 +#: htdocs/luci-static/resources/view/wwand/settings.js:797 msgid "Home PLMN (6F62)" msgstr "" @@ -1132,11 +1132,11 @@ msgstr "" msgid "IPv6 gateway" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:540 +#: htdocs/luci-static/resources/view/wwand/settings.js:542 msgid "Id" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:655 +#: htdocs/luci-static/resources/wwand/modemopts.js:665 msgid "" "If the modem has not answered since boot or since the outage began, pulse " "its reset GPIO once after this many seconds. Only a modem with its own reset " @@ -1166,16 +1166,16 @@ msgstr "" msgid "Installing profile" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:779 +#: htdocs/luci-static/resources/view/wwand/modems.js:789 #: htdocs/luci-static/resources/view/wwand/status.js:95 msgid "Interface" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1278 +#: htdocs/luci-static/resources/view/wwand/settings.js:1280 msgid "Interfaces" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:775 +#: htdocs/luci-static/resources/view/wwand/modems.js:785 msgid "" "Interfaces still using a stock cellular protocol (qmi / mbim / ncm / " "modemmanager) that wwand does not manage yet. Select the ones to migrate and " @@ -1190,6 +1190,10 @@ msgstr "" msgid "International Mobile Equipment Identity — the modem hardware serial" msgstr "" +#: htdocs/luci-static/resources/wwand/modemopts.js:557 +msgid "Keep the modem's order" +msgstr "" + #: htdocs/luci-static/resources/wwand/modemopts.js:349 msgid "" "Kernel datapath that carries this modem's data sessions. Leave on automatic " @@ -1206,7 +1210,7 @@ msgstr "" msgid "LTE" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1301 +#: htdocs/luci-static/resources/view/wwand/settings.js:1303 msgid "LTE bands" msgstr "" @@ -1214,8 +1218,8 @@ msgstr "" msgid "LTE carriers" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:910 -#: htdocs/luci-static/resources/wwand/modemopts.js:597 +#: htdocs/luci-static/resources/view/wwand/settings.js:912 +#: htdocs/luci-static/resources/wwand/modemopts.js:607 msgid "LTE cell lock" msgstr "" @@ -1231,7 +1235,7 @@ msgstr "" msgid "Last error" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:255 +#: htdocs/luci-static/resources/view/wwand/settings.js:257 msgid "Later" msgstr "" @@ -1239,24 +1243,24 @@ msgstr "" msgid "Latitude and longitude as the receiver last reported them." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1310 +#: htdocs/luci-static/resources/view/wwand/settings.js:1312 msgid "" "Leave every band unchecked (and the fallback empty) to let the modem use all " "supported bands." msgstr "" #: htdocs/luci-static/resources/wwand/modemopts.js:178 -#: htdocs/luci-static/resources/wwand/modemopts.js:738 +#: htdocs/luci-static/resources/wwand/modemopts.js:748 msgid "Leave modem setting unchanged" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:746 +#: htdocs/luci-static/resources/wwand/modemopts.js:756 msgid "" "Let wwand reprogram mismatched modem-side settings (attach profile, etc.) " "when it detects them." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:725 +#: htdocs/luci-static/resources/wwand/modemopts.js:735 msgid "" "Let wwand step the system clock from the GNSS time — only while the clock is " "plainly unset, so it never fights the NTP client. Off by default. For " @@ -1279,16 +1283,16 @@ msgid "" "not stored on the router and starts empty after a reload." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:599 +#: htdocs/luci-static/resources/view/wwand/modems.js:609 msgid "Live status page (signal, cells, connection) for this modem" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:742 -#: htdocs/luci-static/resources/view/wwand/settings.js:1143 +#: htdocs/luci-static/resources/view/wwand/settings.js:744 +#: htdocs/luci-static/resources/view/wwand/settings.js:1145 msgid "Load" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1122 +#: htdocs/luci-static/resources/view/wwand/settings.js:1124 msgid "Loading…" msgstr "" @@ -1296,12 +1300,12 @@ msgstr "" msgid "Lock" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:598 +#: htdocs/luci-static/resources/wwand/modemopts.js:608 msgid "" "Lock to LTE cells, one \"earfcn:pci\" per entry (several = a cell list)." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:609 +#: htdocs/luci-static/resources/wwand/modemopts.js:619 msgid "" "Lock to a 5G SA cell: \"pci:arfcn:scs:band\" — the subcarrier spacing is in " "kHz (15, 30, 60 …). The \"Lock this 5G cell\" button fills in 30 as a " @@ -1329,11 +1333,11 @@ msgstr "" msgid "MBIM, over the QMI passthrough" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:571 +#: htdocs/luci-static/resources/wwand/modemopts.js:581 msgid "MCC" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:576 +#: htdocs/luci-static/resources/wwand/modemopts.js:586 msgid "MNC" msgstr "" @@ -1369,30 +1373,30 @@ msgstr "" msgid "Max rate" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1109 +#: htdocs/luci-static/resources/view/wwand/settings.js:1111 msgid "Message" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:774 +#: htdocs/luci-static/resources/view/wwand/modems.js:784 msgid "Migratable interfaces" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:759 +#: htdocs/luci-static/resources/view/wwand/modems.js:769 msgid "" "Migrate %d interface(s) to proto wwand? Each is converted in place — its " "name, firewall zone and IP settings are kept — and wwand takes over managing " "it." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:771 +#: htdocs/luci-static/resources/view/wwand/modems.js:781 msgid "Migrate selected" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:764 +#: htdocs/luci-static/resources/view/wwand/modems.js:774 msgid "Migrated %d interface(s); reloading…" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:768 +#: htdocs/luci-static/resources/view/wwand/modems.js:778 msgid "Migration failed: %s" msgstr "" @@ -1400,7 +1404,7 @@ msgstr "" msgid "Minimum severity" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:572 +#: htdocs/luci-static/resources/wwand/modemopts.js:582 msgid "Mobile Country Code for manual network selection." msgstr "" @@ -1408,7 +1412,7 @@ msgstr "" msgid "Mobile Modems" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:577 +#: htdocs/luci-static/resources/wwand/modemopts.js:587 msgid "Mobile Network Code (requires MCC)." msgstr "" @@ -1436,12 +1440,12 @@ msgstr "" msgid "Modem Status" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1245 +#: htdocs/luci-static/resources/view/wwand/settings.js:1247 #: root/usr/share/luci/menu.d/luci-app-wwand.json:52 msgid "Modem Tools" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:479 +#: htdocs/luci-static/resources/view/wwand/modems.js:489 msgid "Modem attached (%s)." msgstr "" @@ -1452,7 +1456,7 @@ msgid "" "number needed." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:935 +#: htdocs/luci-static/resources/view/wwand/settings.js:937 msgid "Modem currently locked: %s — the live read-back of the lock editor." msgstr "" @@ -1460,7 +1464,7 @@ msgstr "" msgid "Modem default" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:479 +#: htdocs/luci-static/resources/view/wwand/modems.js:489 msgid "Modem detached (%s)." msgstr "" @@ -1468,7 +1472,7 @@ msgstr "" msgid "Modem device" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:625 +#: htdocs/luci-static/resources/wwand/modemopts.js:635 msgid "Modem init delay" msgstr "" @@ -1478,11 +1482,11 @@ msgid "" "registers." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:959 +#: htdocs/luci-static/resources/view/wwand/settings.js:961 msgid "Modem memory" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:343 +#: htdocs/luci-static/resources/view/wwand/modems.js:353 msgid "Modem repowered (%s)." msgstr "" @@ -1494,17 +1498,17 @@ msgstr "" msgid "Modem reset failed: %s" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:302 +#: htdocs/luci-static/resources/view/wwand/modems.js:312 #: htdocs/luci-static/resources/wwand/modemopts.js:194 msgid "Modem reset triggered (%s)." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:831 +#: htdocs/luci-static/resources/view/wwand/settings.js:833 msgid "Modem settings applied." msgstr "" #: htdocs/luci-static/resources/view/wwand/modems.js:122 -#: htdocs/luci-static/resources/view/wwand/settings.js:1274 +#: htdocs/luci-static/resources/view/wwand/settings.js:1276 #: root/usr/share/luci/menu.d/luci-app-wwand.json:39 msgid "Modems" msgstr "" @@ -1521,11 +1525,11 @@ msgstr "" msgid "Mux channel %d" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:740 +#: htdocs/luci-static/resources/view/wwand/settings.js:742 msgid "NAS" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:785 +#: htdocs/luci-static/resources/view/wwand/settings.js:787 msgid "" "NAS = QMI preferred-networks list; User = SIM EF 6F60 (preference, not a " "lock); Forbidden = SIM EF 6F7B (FPLMN) — networks the modem must NOT use. " @@ -1533,7 +1537,7 @@ msgid "" "before every radio-on (survives modem reboots)." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:595 +#: htdocs/luci-static/resources/view/wwand/settings.js:597 msgid "NAS preferred networks" msgstr "" @@ -1541,7 +1545,7 @@ msgstr "" msgid "NCM (AT + ethernet data port)" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:692 +#: htdocs/luci-static/resources/wwand/modemopts.js:702 msgid "" "NCM modems only. How many consecutive status polls may list no data context " "— with no received traffic in between — before wwand declares the bearer " @@ -1559,19 +1563,19 @@ msgstr "" msgid "NMEA stream" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1307 +#: htdocs/luci-static/resources/view/wwand/settings.js:1309 msgid "NR5G NSA bands" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1305 +#: htdocs/luci-static/resources/view/wwand/settings.js:1307 msgid "NR5G SA bands" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1303 +#: htdocs/luci-static/resources/view/wwand/settings.js:1305 msgid "NR5G bands" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:789 +#: htdocs/luci-static/resources/view/wwand/settings.js:791 #: htdocs/luci-static/resources/view/wwand/sims.js:89 #: htdocs/luci-static/resources/wwand/simlist.js:144 msgid "Name" @@ -1603,7 +1607,7 @@ msgid "" "QRTR bus (no cdc-wdm); its data device is the interface's L3 device." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:364 +#: htdocs/luci-static/resources/view/wwand/modems.js:374 msgid "Network re-attach triggered (%s)." msgstr "" @@ -1619,7 +1623,7 @@ msgstr "" msgid "Network → Modems" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:421 +#: htdocs/luci-static/resources/view/wwand/modems.js:431 msgid "New PIN (4-8 digits)" msgstr "" @@ -1627,7 +1631,7 @@ msgstr "" msgid "No SIM card seen yet." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:813 +#: htdocs/luci-static/resources/view/wwand/settings.js:815 msgid "No change — the modem already runs these settings." msgstr "" @@ -1639,15 +1643,15 @@ msgstr "" msgid "No exchange has succeeded in the selected protocol yet." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:757 +#: htdocs/luci-static/resources/view/wwand/modems.js:767 msgid "No interface selected." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:985 +#: htdocs/luci-static/resources/view/wwand/settings.js:987 msgid "No messages stored." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1154 +#: htdocs/luci-static/resources/view/wwand/settings.js:1156 msgid "No modem present." msgstr "" @@ -1675,7 +1679,7 @@ msgstr "" msgid "No profiles installed" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:773 +#: htdocs/luci-static/resources/view/wwand/settings.js:775 msgid "" "No saved list is attached to this modem yet — edit above and \"Save as list " "& attach\"." @@ -1697,7 +1701,7 @@ msgstr "" msgid "No usable channel to the SIM on this modem." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:850 +#: htdocs/luci-static/resources/view/wwand/settings.js:852 msgid "No wwand interface found — the cell lock is stored on the modem." msgstr "" @@ -1709,7 +1713,7 @@ msgstr "" msgid "None" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:625 +#: htdocs/luci-static/resources/view/wwand/settings.js:627 msgid "" "Not written: %d entries of this list are reported by the modem by name " "without a complete MCC/MNC, and writing the list would drop them." @@ -1723,7 +1727,7 @@ msgstr "" msgid "Off" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:793 +#: htdocs/luci-static/resources/view/wwand/settings.js:795 msgid "On the card" msgstr "" @@ -1735,7 +1739,7 @@ msgstr "" msgid "On, card detected at low level" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:646 +#: htdocs/luci-static/resources/wwand/modemopts.js:656 msgid "One to three whole numbers, rising, e.g. \"2 6 12\"" msgstr "" @@ -1751,15 +1755,15 @@ msgstr "" msgid "Only this subsystem" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:395 -#: htdocs/luci-static/resources/view/wwand/settings.js:601 +#: htdocs/luci-static/resources/view/wwand/settings.js:397 +#: htdocs/luci-static/resources/view/wwand/settings.js:603 #: htdocs/luci-static/resources/view/wwand/status.js:948 #: htdocs/luci-static/resources/wwand/format.js:480 #: htdocs/luci-static/resources/wwand/netsel.js:130 msgid "Operator" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:794 +#: htdocs/luci-static/resources/view/wwand/settings.js:796 msgid "Operator-controlled (6F61)" msgstr "" @@ -1767,7 +1771,7 @@ msgstr "" msgid "Optional APN for this card, overriding the interface/default APN." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:584 +#: htdocs/luci-static/resources/wwand/modemopts.js:594 msgid "" "Optional: a saved PLMN list (config wwand_plmnlist) the daemon restores " "before every radio-on. Edit lists on the modem status page." @@ -1817,7 +1821,7 @@ msgstr "" msgid "PDP type" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:424 +#: htdocs/luci-static/resources/view/wwand/modems.js:434 #: htdocs/luci-static/resources/wwand/format.js:492 #: htdocs/luci-static/resources/wwand/simlist.js:175 msgid "PIN" @@ -1831,7 +1835,7 @@ msgstr "" msgid "PIN query:" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:453 +#: htdocs/luci-static/resources/view/wwand/modems.js:463 msgid "PIN release requested — the modem restarts its bring-up." msgstr "" @@ -1851,7 +1855,7 @@ msgstr "" msgid "PLMN" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:420 +#: htdocs/luci-static/resources/view/wwand/modems.js:430 msgid "PUK (8 digits)" msgstr "" @@ -1867,7 +1871,7 @@ msgstr "" msgid "Parent device" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:668 +#: htdocs/luci-static/resources/wwand/modemopts.js:678 msgid "Park the radio when idle" msgstr "" @@ -1879,7 +1883,7 @@ msgstr "" msgid "Password for the attach bearer." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:667 +#: htdocs/luci-static/resources/view/wwand/modems.js:677 msgid "Path" msgstr "" @@ -1902,11 +1906,11 @@ msgstr "" msgid "Permanently DELETE profile %s from the eUICC?" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:914 +#: htdocs/luci-static/resources/view/wwand/settings.js:916 msgid "Persist in modem" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:613 +#: htdocs/luci-static/resources/wwand/modemopts.js:623 msgid "Persist lock in modem" msgstr "" @@ -1942,15 +1946,15 @@ msgstr "" msgid "Position" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:782 +#: htdocs/luci-static/resources/view/wwand/settings.js:784 msgid "Preferred PLMN lists" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:783 +#: htdocs/luci-static/resources/view/wwand/settings.js:785 msgid "Preferred-PLMN editor —" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:583 +#: htdocs/luci-static/resources/wwand/modemopts.js:593 #: htdocs/luci-static/resources/wwand/simlist.js:228 msgid "Preferred-PLMN list" msgstr "" @@ -2005,15 +2009,15 @@ msgstr "" msgid "Profiles: not read" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:780 +#: htdocs/luci-static/resources/view/wwand/modems.js:790 msgid "Protocol" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1410 +#: htdocs/luci-static/resources/view/wwand/settings.js:1412 msgid "Protocol switch failed: %s." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1409 +#: htdocs/luci-static/resources/view/wwand/settings.js:1411 msgid "Protocol switch to %s issued — the modem resets." msgstr "" @@ -2025,7 +2029,7 @@ msgstr "" msgid "Provider confirmations (notifications)" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:669 +#: htdocs/luci-static/resources/wwand/modemopts.js:679 msgid "" "Put the modem's radio into low power once no interface on this modem is up — " "for battery and solar installs, where an idle modem still spends a couple of " @@ -2065,7 +2069,7 @@ msgstr "" msgid "QRTR node" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:737 +#: htdocs/luci-static/resources/wwand/modemopts.js:747 msgid "" "Quectel and ASR modems: which NMEA sentences the receiver streams. Written " "when wwand starts the receiver; saving a change restarts this modem's bring-" @@ -2092,7 +2096,7 @@ msgstr "" msgid "RAT" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1295 +#: htdocs/luci-static/resources/view/wwand/settings.js:1297 #: htdocs/luci-static/resources/wwand/modemopts.js:549 msgid "RAT priority" msgstr "" @@ -2165,7 +2169,7 @@ msgstr "" msgid "Radio stack" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1291 +#: htdocs/luci-static/resources/view/wwand/settings.js:1293 msgid "Radio technologies" msgstr "" @@ -2177,24 +2181,24 @@ msgstr "" msgid "Radio technology, manual operator selection and cell lock." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:360 +#: htdocs/luci-static/resources/view/wwand/modems.js:370 msgid "" "Re-attach modem \"%s\" to the network now? It briefly deregisters and re-" "registers; the connection recovers automatically." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:366 +#: htdocs/luci-static/resources/view/wwand/modems.js:376 msgid "Re-attach unavailable: %s." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:316 +#: htdocs/luci-static/resources/view/wwand/modems.js:326 msgid "" "Re-initialise the SIM card in the active slot: power it off and on and read " "it again, e.g. after swapping the card. The connection drops and recovers on " "its own; not a modem reset." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:319 +#: htdocs/luci-static/resources/view/wwand/modems.js:329 msgid "" "Re-initialise the SIM of modem \"%s\" now? Its connection drops and recovers " "automatically." @@ -2204,7 +2208,7 @@ msgstr "" msgid "Read from the modem directly (slot %d), bypassing ES10:" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:237 +#: htdocs/luci-static/resources/view/wwand/settings.js:239 msgid "" "Read only: the list has entries this interface cannot name (IoT " "technologies). Change it with the modem vendor's command." @@ -2225,38 +2229,38 @@ msgstr "" msgid "Reader" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:369 -#: htdocs/luci-static/resources/view/wwand/modems.js:518 +#: htdocs/luci-static/resources/view/wwand/modems.js:379 +#: htdocs/luci-static/resources/view/wwand/modems.js:528 msgid "Reattach" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:308 -#: htdocs/luci-static/resources/view/wwand/modems.js:520 +#: htdocs/luci-static/resources/view/wwand/modems.js:318 +#: htdocs/luci-static/resources/view/wwand/modems.js:530 msgid "Reboot" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:631 +#: htdocs/luci-static/resources/wwand/modemopts.js:641 msgid "Reboot after N failures" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:660 +#: htdocs/luci-static/resources/wwand/modemopts.js:670 msgid "Reboot after N protocol errors" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:661 +#: htdocs/luci-static/resources/wwand/modemopts.js:671 msgid "" "Reboot after this many consecutive control-protocol errors. Gated by the " "failure-reboot setting above — with reboot disabled it never fires." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:632 +#: htdocs/luci-static/resources/wwand/modemopts.js:642 msgid "" "Reboot the router after this many failed connection attempts. 0 = never " "reboot — the hardware recovery rungs (op-mode cycle, modem reset, GPIO/" "repower) still run and the ladder then keeps retrying." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1108 +#: htdocs/luci-static/resources/view/wwand/settings.js:1110 msgid "Received" msgstr "" @@ -2270,7 +2274,7 @@ msgstr "" msgid "Recovery" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:640 +#: htdocs/luci-static/resources/wwand/modemopts.js:650 msgid "Recovery steps" msgstr "" @@ -2316,12 +2320,12 @@ msgid "" "explains why the attach was refused" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:328 -#: htdocs/luci-static/resources/view/wwand/modems.js:517 +#: htdocs/luci-static/resources/view/wwand/modems.js:338 +#: htdocs/luci-static/resources/view/wwand/modems.js:527 msgid "Reinit SIM" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:428 +#: htdocs/luci-static/resources/view/wwand/modems.js:438 msgid "Release PIN — SIM %s" msgstr "" @@ -2329,7 +2333,7 @@ msgstr "" msgid "Release secondary AT port" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:729 +#: htdocs/luci-static/resources/view/wwand/settings.js:731 msgid "Reload from modem" msgstr "" @@ -2341,16 +2345,16 @@ msgstr "" msgid "Reported by the optional package %s" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:349 -#: htdocs/luci-static/resources/view/wwand/modems.js:521 +#: htdocs/luci-static/resources/view/wwand/modems.js:359 +#: htdocs/luci-static/resources/view/wwand/modems.js:531 msgid "Repower" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:673 +#: htdocs/luci-static/resources/wwand/modemopts.js:683 msgid "Repower / reset duration" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:345 +#: htdocs/luci-static/resources/view/wwand/modems.js:355 msgid "Repower unavailable: %s." msgstr "" @@ -2369,7 +2373,7 @@ msgstr "" msgid "Reset GPIO fallback" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:654 +#: htdocs/luci-static/resources/wwand/modemopts.js:664 msgid "Reset a never-answering modem after (s)" msgstr "" @@ -2377,7 +2381,7 @@ msgstr "" msgid "Reset modem" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:298 +#: htdocs/luci-static/resources/view/wwand/modems.js:308 msgid "" "Reset modem \"%s\" now? Its connection(s) will drop briefly and recover " "automatically." @@ -2387,11 +2391,11 @@ msgstr "" msgid "Reset modem now" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1326 +#: htdocs/luci-static/resources/view/wwand/settings.js:1328 msgid "Reset radio/band/usage/roaming settings to defaults?" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:686 +#: htdocs/luci-static/resources/wwand/modemopts.js:696 msgid "" "Reset the modem when, with a connection up, the kernel reports its transmit " "queue stuck for this many seconds, i.e. the modem has stopped taking packets " @@ -2407,16 +2411,16 @@ msgid "" "or dropped off USB." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1370 +#: htdocs/luci-static/resources/view/wwand/settings.js:1372 msgid "Reset to defaults" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:304 +#: htdocs/luci-static/resources/view/wwand/modems.js:314 #: htdocs/luci-static/resources/wwand/modemopts.js:196 msgid "Reset unavailable: %s." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:295 +#: htdocs/luci-static/resources/view/wwand/modems.js:305 msgid "" "Reset/reboot this modem: its own soft reset first, so it can shut down " "cleanly; a configured reset GPIO is pulsed only if the modem does not " @@ -2431,21 +2435,21 @@ msgstr "" msgid "Restart modem now" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:680 +#: htdocs/luci-static/resources/wwand/modemopts.js:690 msgid "" "Restart the connection after this many seconds without received packets (0 = " "off)." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:770 +#: htdocs/luci-static/resources/view/wwand/settings.js:772 msgid "Restore failed: %s" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:764 +#: htdocs/luci-static/resources/view/wwand/settings.js:766 msgid "Restore now" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1302 +#: htdocs/luci-static/resources/view/wwand/settings.js:1304 msgid "" "Restrict LTE to these bands. Leave everything unchecked to let the modem use " "all bands it supports — a narrowed list is a way to pin a known-good band, " @@ -2460,14 +2464,14 @@ msgstr "" msgid "Revision" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1415 +#: htdocs/luci-static/resources/view/wwand/settings.js:1417 msgid "" "Rewrites the modem firmware mode (QMI ↔ MBIM). The modem resets; use only " "when the other mode is known to work on this module. The daemon clears a " "contradicting \"Control protocol\" pin when the switch succeeds." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1299 +#: htdocs/luci-static/resources/view/wwand/settings.js:1301 msgid "Roaming" msgstr "" @@ -2503,7 +2507,7 @@ msgstr "" msgid "SIM PIN query enabled." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:958 +#: htdocs/luci-static/resources/view/wwand/settings.js:960 #: htdocs/luci-static/resources/view/wwand/status.js:653 #: htdocs/luci-static/resources/wwand/format.js:456 msgid "SIM card" @@ -2522,7 +2526,7 @@ msgstr "" msgid "SIM hot-plug detection" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:323 +#: htdocs/luci-static/resources/view/wwand/modems.js:333 msgid "SIM in slot %d re-initialised." msgstr "" @@ -2530,7 +2534,7 @@ msgstr "" msgid "SIM overrides" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:325 +#: htdocs/luci-static/resources/view/wwand/modems.js:335 msgid "SIM re-initialisation failed: %s." msgstr "" @@ -2546,7 +2550,7 @@ msgstr "" msgid "SIM toolkit" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:448 +#: htdocs/luci-static/resources/view/wwand/modems.js:458 msgid "" "SIM unblocked, new PIN set. Update the configured PIN in the modem settings!" msgstr "" @@ -2563,7 +2567,7 @@ msgstr "" msgid "SINR LTE" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1138 +#: htdocs/luci-static/resources/view/wwand/settings.js:1140 msgid "SMS" msgstr "" @@ -2571,28 +2575,28 @@ msgstr "" msgid "Satellites" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:401 -#: htdocs/luci-static/resources/view/wwand/modems.js:516 +#: htdocs/luci-static/resources/view/wwand/modems.js:411 +#: htdocs/luci-static/resources/view/wwand/modems.js:526 msgid "Save SIM" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:731 +#: htdocs/luci-static/resources/view/wwand/settings.js:733 msgid "Save as list & attach" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:694 +#: htdocs/luci-static/resources/view/wwand/settings.js:696 msgid "Save as list — name:" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:939 +#: htdocs/luci-static/resources/view/wwand/settings.js:941 msgid "Save cell lock" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:787 +#: htdocs/luci-static/resources/view/wwand/settings.js:789 msgid "Saved PLMN lists" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:722 +#: htdocs/luci-static/resources/view/wwand/settings.js:724 msgid "Saved list \"%s\" (%d record(s)) and attached it to this modem." msgstr "" @@ -2618,7 +2622,7 @@ msgid "" "before the reset GPIO is pulsed (default 30)." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:698 +#: htdocs/luci-static/resources/wwand/modemopts.js:708 msgid "Seconds between throughput/signal telemetry samples while connected." msgstr "" @@ -2629,30 +2633,30 @@ msgid "" "still current." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:674 +#: htdocs/luci-static/resources/wwand/modemopts.js:684 msgid "" "Seconds the modem is held de-powered during a recovery power-cycle (or held " "in reset, when a reset GPIO is used). Default 30 s." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:626 +#: htdocs/luci-static/resources/wwand/modemopts.js:636 msgid "Seconds to wait before initializing the modem." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:523 +#: htdocs/luci-static/resources/view/wwand/settings.js:525 #: htdocs/luci-static/resources/wwand/netsel.js:112 msgid "Select" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1037 +#: htdocs/luci-static/resources/view/wwand/settings.js:1039 msgid "Select all messages" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1017 +#: htdocs/luci-static/resources/view/wwand/settings.js:1019 msgid "Select this message" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:518 +#: htdocs/luci-static/resources/view/wwand/settings.js:520 msgid "Selecting the carrier configuration failed." msgstr "" @@ -2664,7 +2668,7 @@ msgstr "" msgid "Send confirmations" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1107 +#: htdocs/luci-static/resources/view/wwand/settings.js:1109 msgid "Sender" msgstr "" @@ -2679,7 +2683,7 @@ msgid "" "is not NMEA." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:668 +#: htdocs/luci-static/resources/view/wwand/modems.js:678 msgid "Serial" msgstr "" @@ -2699,11 +2703,11 @@ msgstr "" msgid "Set automatic" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:724 +#: htdocs/luci-static/resources/wwand/modemopts.js:734 msgid "Set the clock from GNSS" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:706 +#: htdocs/luci-static/resources/wwand/modemopts.js:716 msgid "Set the clock from the network" msgstr "" @@ -2715,7 +2719,7 @@ msgstr "" msgid "Show raw lpac log" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1294 +#: htdocs/luci-static/resources/view/wwand/settings.js:1296 msgid "" "Shown, not set: this modem picks its technologies with its own RAT setting, " "and a band edit keeps it as it is." @@ -2753,7 +2757,7 @@ msgstr "" msgid "Source" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:911 +#: htdocs/luci-static/resources/view/wwand/settings.js:913 msgid "" "Space/comma separated \"earfcn:pci\" entries (several = a cell list). See " "Status → Modem for the live cells and their lock values." @@ -2777,7 +2781,7 @@ msgid "Starting download…" msgstr "" #: htdocs/luci-static/resources/view/wwand/modems.js:208 -#: htdocs/luci-static/resources/view/wwand/settings.js:539 +#: htdocs/luci-static/resources/view/wwand/settings.js:541 #: htdocs/luci-static/resources/view/wwand/sims.js:92 #: htdocs/luci-static/resources/view/wwand/status.js:100 #: htdocs/luci-static/resources/view/wwand/status.js:556 @@ -2786,13 +2790,13 @@ msgstr "" msgid "State" msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:599 -#: htdocs/luci-static/resources/view/wwand/settings.js:1276 +#: htdocs/luci-static/resources/view/wwand/modems.js:609 +#: htdocs/luci-static/resources/view/wwand/settings.js:1278 #: htdocs/luci-static/resources/wwand/netsel.js:133 msgid "Status" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:707 +#: htdocs/luci-static/resources/wwand/modemopts.js:717 msgid "" "Step the system clock to the network's time (NITZ) when it is more than two " "minutes off. Smaller differences are left to NTP. A router without a real-" @@ -2800,21 +2804,21 @@ msgid "" "reachable. The time zone is not changed." msgstr "" -#: htdocs/luci-static/resources/view/wwand/modems.js:471 +#: htdocs/luci-static/resources/view/wwand/modems.js:481 msgid "" "Stop the data sessions and detach the modem from the network (radio stays " "on). It stays detached until you attach it again or the modem is reset." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1140 +#: htdocs/luci-static/resources/view/wwand/settings.js:1142 msgid "Storage" msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:614 +#: htdocs/luci-static/resources/wwand/modemopts.js:624 msgid "Store the cell lock in modem non-volatile memory." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:915 +#: htdocs/luci-static/resources/view/wwand/settings.js:917 msgid "Store the lock in modem non-volatile memory" msgstr "" @@ -2827,11 +2831,11 @@ msgstr "" msgid "Switch now" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1413 +#: htdocs/luci-static/resources/view/wwand/settings.js:1415 msgid "Switch protocol" msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1404 +#: htdocs/luci-static/resources/view/wwand/settings.js:1406 msgid "" "Switch the modem firmware to %s? The modem resets and re-enumerates — its " "connections come back on their own. A \"Control protocol\" pinned on the " @@ -2839,7 +2843,7 @@ msgid "" "contradicts disables hardware recovery for this modem." msgstr "" -#: htdocs/luci-static/resources/view/wwand/settings.js:1403 +#: htdocs/luci-static/resources/view/wwand/settings.js:1405 msgid "" "Switch the modem firmware to MBIM? The modem resets and re-enumerates. Some " "firmwares reject MBIM even though the switch itself succeeds — if the modem " @@ -2848,7 +2852,7 @@ msgid "" "contradicts disables hardware recovery for this modem." msgstr "" -#: htdocs/luci-static/resources/wwand/modemopts.js:720 +#: htdocs/luci-static/resources/wwand/modemopts.js:730 msgid "" "Switch the modem's GNSS on. With wwand-gps installed wwand reads " "the position itself: from the modem's NMEA port (reported as gps_port +# +# 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 diff --git a/luci-proto-hev2socks/htdocs/luci-static/resources/protocol/h2s.js b/luci-proto-hev2socks/htdocs/luci-static/resources/protocol/h2s.js new file mode 100644 index 000000000..04daefba9 --- /dev/null +++ b/luci-proto-hev2socks/htdocs/luci-static/resources/protocol/h2s.js @@ -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'; + } +}); diff --git a/luci-proto-hev2socks/root/lib/netifd/proto/h2s.sh b/luci-proto-hev2socks/root/lib/netifd/proto/h2s.sh new file mode 100755 index 000000000..da9b2c297 --- /dev/null +++ b/luci-proto-hev2socks/root/lib/netifd/proto/h2s.sh @@ -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 < "$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 + diff --git a/safeshield/CHANGELOG.md b/safeshield/CHANGELOG.md index 10fbb82e1..5c3aabf22 100644 --- a/safeshield/CHANGELOG.md +++ b/safeshield/CHANGELOG.md @@ -1,5 +1,48 @@ # 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 - Bump version for release. diff --git a/safeshield/Makefile b/safeshield/Makefile index 4ed584919..981b7af1b 100644 --- a/safeshield/Makefile +++ b/safeshield/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=safeshield -PKG_VERSION:=0.3.25 -PKG_RELEASE:=4 +PKG_VERSION:=0.3.26 +PKG_RELEASE:=5 PKG_MAINTAINER:=Beomjun Kang 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/status.sh $(1)/usr/lib/safeshield/status.sh $(INSTALL_BIN) ./files/usr/lib/safeshield/utils.sh $(1)/usr/lib/safeshield/utils.sh + $(INSTALL_DATA) ./files/usr/lib/safeshield/locking.sh $(1)/usr/lib/safeshield/locking.sh $(INSTALL_DATA) ./files/usr/lib/safeshield/config.sh $(1)/usr/lib/safeshield/config.sh $(INSTALL_BIN) ./files/usr/lib/safeshield/identity.sh $(1)/usr/lib/safeshield/identity.sh $(INSTALL_DATA) ./files/usr/lib/safeshield/dns.sh $(1)/usr/lib/safeshield/dns.sh diff --git a/safeshield/README.md b/safeshield/README.md index d5ae1390f..d32353e32 100644 --- a/safeshield/README.md +++ b/safeshield/README.md @@ -399,6 +399,7 @@ Available methods: safeshield.status safeshield.config safeshield.statistics +safeshield.statistics_summary safeshield.config_update safeshield.set_enabled safeshield.refresh @@ -420,6 +421,18 @@ ubus call 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 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 diff --git a/safeshield/files/etc/init.d/safeshield b/safeshield/files/etc/init.d/safeshield index 86b7cb693..28050b9a6 100644 --- a/safeshield/files/etc/init.d/safeshield +++ b/safeshield/files/etc/init.d/safeshield @@ -16,7 +16,7 @@ IPKG_INSTROOT="${IPKG_INSTROOT:-}" export IPKG_INSTROOT 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 extra_command 'apply_local_rules' 'Apply local allow/block rules using the cached Hub artifact' diff --git a/safeshield/files/usr/lib/safeshield/blocklist.sh b/safeshield/files/usr/lib/safeshield/blocklist.sh index 6f2a6d927..5ba38853c 100755 --- a/safeshield/files/usr/lib/safeshield/blocklist.sh +++ b/safeshield/files/usr/lib/safeshield/blocklist.sh @@ -876,7 +876,80 @@ ss_merge_sorted_api_sources() { 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() { + 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 api_blocks="${SS_TMP_DIR}/api-blocks.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_api_allows="${SS_TMP_DIR}/effective-api-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 valid_count + local rule_prefix rule_suffix final="$(ss_blocklist_tmp_path)" || return 1 : >"$final" || return 1 @@ -929,39 +1001,39 @@ ss_merge_lists() { rm -f "$final" 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" return 1 } - { - [ -f "$effective_local_blocks" ] && cat "$effective_local_blocks" - [ -f "$effective_api_blocks" ] && cat "$effective_api_blocks" - } | sort -u >"$final_blocks" || { + # Sort by the domain field (between slashes), not the whole dnsmasq rule. + # Sorting full rules changes legacy ordering for domain-prefix pairs such + # as foo and foo-bar, causing otherwise identical refreshes to differ. + # 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" return 1 } - + # Preserve the cancellation point from the original merge pipeline. ss_should_stop && { rm -f "$final" 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 awk '{ print "server=/" $0 "/#" }' "$effective_allows" >>"$final" || { rm -f "$final" diff --git a/safeshield/files/usr/lib/safeshield/core.sh b/safeshield/files/usr/lib/safeshield/core.sh index 2333b9453..ced2c76c0 100755 --- a/safeshield/files/usr/lib/safeshield/core.sh +++ b/safeshield/files/usr/lib/safeshield/core.sh @@ -46,7 +46,6 @@ readonly SS_MAX_ARTIFACT_SOURCES=16 readonly SS_PREV_BLOCKLIST_GZ="/tmp/safeshield.prev.blocklist.gz" readonly SS_RUNTIME_OUT="${SS_TMP_DIR}/runtime.out" readonly SS_REFRESH_LOCK='/var/lock/safeshield-refresh.lock' -readonly SS_REFRESH_LOCK_FD=307 readonly SS_LOCAL_APPLY_STATE="${SS_TMP_DIR}/local-apply.state" readonly SS_STATISTICS_DIR="${SS_TMP_DIR}/statistics" readonly SS_STATISTICS_STATE_FILE="${SS_STATISTICS_DIR}/state.tsv" @@ -444,7 +443,6 @@ safeshield_apply_local_rules() { *) log_error "Failed to merge cached Hub artifact with local rules" ss_mark_local_apply_failure "local_merge_failed" - ss_restore_and_restart ss_refresh_lock_close return 1 ;; @@ -647,9 +645,7 @@ safeshield_force_download() { *) failure_code="$(ss_resolve_error_code)" ss_status_mark_failure "$failure_code" - if [ "$failure_code" != "safeshield_upgrade_required" ]; then - ss_restore_and_restart - fi + # No blocklist replacement has happened; keep the running DNS service. ss_refresh_lock_close return 1 ;; @@ -672,9 +668,7 @@ safeshield_force_download() { *) failure_code="$(ss_artifact_download_error_code)" ss_status_mark_failure "$failure_code" - if [ "$failure_code" != "safeshield_upgrade_required" ]; then - ss_restore_and_restart - fi + # No blocklist replacement has happened; keep the running DNS service. ss_refresh_lock_close return 1 ;; @@ -689,7 +683,6 @@ safeshield_force_download() { ss_prepare_local_rule_files || { log_error "Failed to build allowlist" ss_status_mark_failure "allowlist_build_failed" - ss_restore_and_restart ss_refresh_lock_close return 1 } @@ -711,7 +704,6 @@ safeshield_force_download() { *) log_error "Failed to merge API artifact with local overrides" ss_status_mark_failure "merge_failed" - ss_restore_and_restart ss_refresh_lock_close return 1 ;; diff --git a/safeshield/files/usr/lib/safeshield/locking.sh b/safeshield/files/usr/lib/safeshield/locking.sh new file mode 100644 index 000000000..3da968b8c --- /dev/null +++ b/safeshield/files/usr/lib/safeshield/locking.sh @@ -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 +} diff --git a/safeshield/files/usr/lib/safeshield/statistics/00-common.awk b/safeshield/files/usr/lib/safeshield/statistics/00-common.awk index e5084b276..c221c7703 100644 --- a/safeshield/files/usr/lib/safeshield/statistics/00-common.awk +++ b/safeshield/files/usr/lib/safeshield/statistics/00-common.awk @@ -30,6 +30,16 @@ function device_bucket_key(key, bucket) { function prune_buckets(now, cutoff, bucket, composite, parts) { 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) { if ((bucket + 0) < cutoff) { 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 # associative-array element to helper functions during serialization. device_seen[selected] = 1 + aggregate_dirty = 1 device_mac[selected] = "" device_ip[selected] = "" device_hostname[selected] = "" diff --git a/safeshield/files/usr/lib/safeshield/statistics/20-identity.awk b/safeshield/files/usr/lib/safeshield/statistics/20-identity.awk index 627ac6e92..655b2870f 100644 --- a/safeshield/files/usr/lib/safeshield/statistics/20-identity.awk +++ b/safeshield/files/usr/lib/safeshield/statistics/20-identity.awk @@ -282,6 +282,7 @@ function migrate_device(from_key, to_key, mac, ip, hostname, client_id, duid, if (!journal_replaying) { journal_deleted_device[from_key] = 1 } + aggregate_dirty = 1 remove_device_metadata(from_key) selected = register_device(to_key, mac, ip, hostname, client_id, duid) if (selected == "") { @@ -533,6 +534,7 @@ function record_device(ip, query_delta, blocked_delta, now, key, bucket, comp bucket = hour_start(now) composite = device_bucket_key(key, bucket) + aggregate_dirty = 1 device_hour_queries[composite] += 0 device_hour_blocked[composite] += 0 if (query_delta > 0) { diff --git a/safeshield/files/usr/lib/safeshield/statistics/50-output.awk b/safeshield/files/usr/lib/safeshield/statistics/50-output.awk index 47a86d415..d64a0e0e5 100644 --- a/safeshield/files/usr/lib/safeshield/statistics/50-output.awk +++ b/safeshield/files/usr/lib/safeshield/statistics/50-output.awk @@ -53,6 +53,12 @@ function save_json_range(path, now, first_hour, last_hour, include_empty_devices if (!is_unknown_output_device(key)) { 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) { composite = device_bucket_key(key, bucket) 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) if (serialize_snapshot) { 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) { recompute_totals() + aggregate_dirty = 0 } updated_at = now if (!ensure_snapshot_sequence_reservation(now)) { diff --git a/safeshield/files/usr/lib/safeshield/statistics/90-main.awk b/safeshield/files/usr/lib/safeshield/statistics/90-main.awk index 924704d83..1f50167be 100644 --- a/safeshield/files/usr/lib/safeshield/statistics/90-main.awk +++ b/safeshield/files/usr/lib/safeshield/statistics/90-main.awk @@ -9,6 +9,7 @@ function record_event(query_delta, blocked_delta, client_ip, now, bucket) { now = current_time() bucket = hour_start(now) snapshot_dirty = 1 + aggregate_dirty = 1 queries[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) { + aggregate_dirty = 1 queries[bucket] += 0 blocked[bucket] += 0 queries[bucket] += query_delta @@ -184,6 +186,9 @@ BEGIN { } event_index = 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 devices_truncated = 0 last_lease_refresh = 0 diff --git a/safeshield/files/usr/lib/safeshield/status-store.uc b/safeshield/files/usr/lib/safeshield/status-store.uc index a2fef19da..5c1ad6644 100644 --- a/safeshield/files/usr/lib/safeshield/status-store.uc +++ b/safeshield/files/usr/lib/safeshield/status-store.uc @@ -62,6 +62,19 @@ else if (cmd == 'set') { 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') { let code = ARGV[2]; diff --git a/safeshield/files/usr/lib/safeshield/status.sh b/safeshield/files/usr/lib/safeshield/status.sh index db333cb4d..5fdffaa83 100755 --- a/safeshield/files/usr/lib/safeshield/status.sh +++ b/safeshield/files/usr/lib/safeshield/status.sh @@ -10,26 +10,19 @@ ss_status_exec() { } ss_status_apply() { - local tmp="${RUNNING_STATUS_FILE}.tmp" + local tmp="${RUNNING_STATUS_FILE}.tmp.$$" rc=0 mkdir -p "${RUNNING_STATUS_FILE%/*}" || return 1 + json_lock_open || return 1 - ss_status_exec "$@" >"${tmp}" || { - rm -f "${tmp}" - return 1 - } - - [ -s "${tmp}" ] || { - rm -f "${tmp}" - return 1 - } - - mv -f "${tmp}" "${RUNNING_STATUS_FILE}" || { - rm -f "${tmp}" - return 1 - } - - return 0 + ss_status_exec "$@" >"$tmp" || rc=1 + [ "$rc" -eq 0 ] && [ -s "$tmp" ] || rc=1 + if [ "$rc" -eq 0 ]; then + mv -f "$tmp" "$RUNNING_STATUS_FILE" || rc=1 + fi + [ "$rc" -eq 0 ] || rm -f "$tmp" + json_lock_close + return "$rc" } ss_now() { @@ -40,66 +33,77 @@ ss_status_set_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_set health_dnsmasq_binary "" - ss_status_set health_dnsmasq_version "" - ss_status_set health_dnsmasq_features "" - ss_status_set health_dnsmasq_confdir "" - ss_status_set health_dnsmasq_initial_restart "" - ss_status_set health_dnsmasq_final_restart "" - ss_status_set health_dns_runtime "" - ss_status_set health_blocklist_verify "" - ss_status_set health_min_valid_line_count "" - ss_status_set health_max_file_size "" - ss_status_set health_api_resolve "" - ss_status_set health_artifact_download "" - ss_status_set health_artifact_sha256 "" - ss_status_set dnsmasq_version "" - ss_status_set dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION:-2.93}" + ss_status_set_many \ + health_dnsmasq_binary "" \ + health_dnsmasq_version "" \ + health_dnsmasq_features "" \ + health_dnsmasq_confdir "" \ + health_dnsmasq_initial_restart "" \ + health_dnsmasq_final_restart "" \ + health_dns_runtime "" \ + health_blocklist_verify "" \ + health_min_valid_line_count "" \ + health_max_file_size "" \ + health_api_resolve "" \ + health_artifact_download "" \ + health_artifact_sha256 "" \ + dnsmasq_version "" \ + dnsmasq_min_version "${SS_MIN_DNSMASQ_VERSION:-2.93}" } ss_status_reset_blocklist_fields() { - ss_status_set blocklist_installed "0" - ss_status_set blocklist_file_size_kb "0" - ss_status_set blocklist_verification_ok "0" - ss_status_set blocklist_test_domain "" - ss_status_set blocklist_test_domain_sample_count "0" - ss_status_set blocklist_test_domain_success_count "0" - ss_status_set blocklist_test_domains "" - ss_status_set blocklist_backup_available "0" + ss_status_set_many \ + blocklist_installed "0" \ + blocklist_file_size_kb "0" \ + blocklist_verification_ok "0" \ + blocklist_test_domain "" \ + blocklist_test_domain_sample_count "0" \ + blocklist_test_domain_success_count "0" \ + blocklist_test_domains "" \ + blocklist_backup_available "0" } ss_status_reset_artifact_fields() { - ss_status_set entitlement_plan "" - ss_status_set entitlement_status "" - ss_status_set physical_fingerprint "" - ss_status_set fingerprint_version "" - ss_status_set identity_provider "" - ss_status_set identity_source "" - ss_status_set identity_strength "" - ss_status_set identity_profile "" - ss_status_set device_code "" - ss_status_set device_code_source "" - ss_status_set installation_id "" - ss_status_set device_profile "" - ss_status_set artifact_tier "" - ss_status_set artifact_version "" - ss_status_set artifact_sha256 "" - ss_status_set artifact_unique_domains "0" - ss_status_set artifact_rules "0" - ss_status_set artifact_download_url_present "0" - ss_status_set artifact_source_count "0" - ss_status_set artifact_block_source_count "0" - ss_status_set artifact_allow_source_count "0" + ss_status_set_many \ + entitlement_plan "" \ + entitlement_status "" \ + physical_fingerprint "" \ + fingerprint_version "" \ + identity_provider "" \ + identity_source "" \ + identity_strength "" \ + identity_profile "" \ + device_code "" \ + device_code_source "" \ + installation_id "" \ + device_profile "" \ + artifact_tier "" \ + artifact_version "" \ + artifact_sha256 "" \ + artifact_unique_domains "0" \ + artifact_rules "0" \ + artifact_download_url_present "0" \ + artifact_source_count "0" \ + artifact_block_source_count "0" \ + artifact_allow_source_count "0" } ss_status_mark_failure() { local code="$1" - ss_status_set status "error" - ss_status_set_now last_failure - ss_status_set last_result "error" - ss_status_set last_error_code "$code" + ss_status_set_many \ + status "error" \ + last_failure "$(ss_now)" \ + last_result "error" \ + last_error_code "$code" ss_status_add_error "$code" } @@ -109,14 +113,15 @@ ss_status_mark_success() { now="$(ss_now)" ss_status_apply clear_messages >/dev/null 2>&1 || true - ss_status_set status "ready" - ss_status_set stage "done" - ss_status_set last_success "$now" - ss_status_set last_result "success" - ss_status_set last_error_code "" - if is_valid_integer "$interval" && [ "$interval" -gt 0 ] 2>/dev/null; then - ss_status_set next_refresh_at "$((now + interval))" + 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 } @@ -131,9 +136,10 @@ ss_status_prepare_refresh() { is_valid_integer "$last_local_apply_failure" || last_local_apply_failure=0 ss_status_reset - ss_status_set last_success "$last_success" - ss_status_set last_local_apply "$last_local_apply" - ss_status_set last_local_apply_failure "$last_local_apply_failure" + ss_status_set_many \ + last_success "$last_success" \ + last_local_apply "$last_local_apply" \ + last_local_apply_failure "$last_local_apply_failure" } ss_status_set() { @@ -159,22 +165,14 @@ ss_status_add_warning() { } ss_status_reset() { - rm -f "${RUNNING_STATUS_FILE}" "${RUNNING_STATUS_FILE}.tmp" - ss_status_apply reset >/dev/null 2>&1 || return 1 - ss_status_set status "idle" - ss_status_set stage "" - ss_status_set valid_line_count "0" - - ss_status_set last_result "idle" - ss_status_set last_error_code "" - ss_status_set last_attempt "0" - ss_status_set last_success "0" - ss_status_set last_failure "0" - ss_status_set last_local_apply "0" - ss_status_set last_local_apply_failure "0" - ss_status_set next_refresh_at "0" + ss_status_set_many \ + status "idle" stage "" valid_line_count "0" \ + last_result "idle" last_error_code "" \ + last_attempt "0" last_success "0" last_failure "0" \ + last_local_apply "0" last_local_apply_failure "0" \ + next_refresh_at "0" ss_status_reset_health_fields ss_status_reset_blocklist_fields @@ -262,41 +260,15 @@ ss_restore_previous_blocklist() { } ss_refresh_lock_open() { - mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1 - eval "exec ${SS_REFRESH_LOCK_FD}>\"${SS_REFRESH_LOCK}\"" || return 1 - flock -n "${SS_REFRESH_LOCK_FD}" || { - eval "exec ${SS_REFRESH_LOCK_FD}>&-" - return 1 - } + ss_lock_try "${SS_REFRESH_LOCK}.d" } ss_refresh_lock_open_wait() { local timeout="${1:-120}" - local waited=0 - is_valid_integer "$timeout" || timeout=120 - mkdir -p "${SS_REFRESH_LOCK%/*}" || return 1 - eval "exec ${SS_REFRESH_LOCK_FD}>\"${SS_REFRESH_LOCK}\"" || return 1 - - while ! flock -n "${SS_REFRESH_LOCK_FD}"; do - if [ "$waited" -ge "$timeout" ] 2>/dev/null; then - eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true - return 1 - fi - - ss_should_stop && { - eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true - return 130 - } - - sleep 1 - waited=$((waited + 1)) - done - - return 0 + ss_lock_wait "${SS_REFRESH_LOCK}.d" "$timeout" ss_should_stop } ss_refresh_lock_close() { - flock -u "${SS_REFRESH_LOCK_FD}" 2>/dev/null || true - eval "exec ${SS_REFRESH_LOCK_FD}>&-" 2>/dev/null || true + ss_lock_release "${SS_REFRESH_LOCK}.d" } diff --git a/safeshield/files/usr/lib/safeshield/utils.sh b/safeshield/files/usr/lib/safeshield/utils.sh index 50510400f..ff8ae3a9c 100755 --- a/safeshield/files/usr/lib/safeshield/utils.sh +++ b/safeshield/files/usr/lib/safeshield/utils.sh @@ -1,7 +1,9 @@ # shellcheck shell=ash -readonly LOCK_FD=309 -readonly RUNNING_STATUS_LOCK="/var/lock/${PKG_NAME}.lock" +readonly RUNNING_STATUS_LOCK="/var/lock/${PKG_NAME}.lock.d" + +# shellcheck disable=SC1090 +. "${SS_LOCKING_LIB:-${IPKG_INSTROOT:-}/usr/lib/safeshield/locking.sh}" readonly RUNNING_STATUS_FILE="/dev/shm/${PKG_NAME}.status.json" command_exists() { @@ -21,17 +23,23 @@ is_greater_equal() { } json_lock_open() { - mkdir -p "${RUNNING_STATUS_LOCK%/*}" || return 1 - eval "exec ${LOCK_FD}>\"${RUNNING_STATUS_LOCK}\"" || return 1 - flock -x "${LOCK_FD}" || { - eval "exec ${LOCK_FD}>&-" - return 1 - } + ss_lock_wait "$RUNNING_STATUS_LOCK" 10 } json_lock_close() { - flock -u "${LOCK_FD}" 2>/dev/null || true - eval "exec ${LOCK_FD}>&-" 2>/dev/null || true + ss_lock_release "$RUNNING_STATUS_LOCK" +} + +_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() { @@ -62,7 +70,7 @@ _json_set() { json_add_string "$key" "$value" json_select .. 2>/dev/null || true - json_dump >"${RUNNING_STATUS_FILE}" + _json_commit } _json_add_message() { @@ -82,7 +90,7 @@ _json_add_message() { json_add_string info "$value" json_close_object json_select .. 2>/dev/null || true - json_dump >"${RUNNING_STATUS_FILE}" + _json_commit } json() { diff --git a/safeshield/files/usr/libexec/safeshield-refreshd b/safeshield/files/usr/libexec/safeshield-refreshd index bbb4ea7d5..e5ede34a4 100755 --- a/safeshield/files/usr/libexec/safeshield-refreshd +++ b/safeshield/files/usr/libexec/safeshield-refreshd @@ -14,20 +14,26 @@ export IPKG_INSTROOT . /usr/lib/safeshield/core.sh readonly SS_REFRESHD_LOCK='/var/lock/safeshield-refreshd.lock' -readonly SS_REFRESHD_LOCK_FD=308 +refreshd_owned=0 ss_should_terminate=0 ss_sleep_pid='' refreshd_lock() { - mkdir -p "${SS_REFRESHD_LOCK%/*}" || return 1 - eval "exec ${SS_REFRESHD_LOCK_FD}>\"${SS_REFRESHD_LOCK}\"" || return 1 - flock -n "${SS_REFRESHD_LOCK_FD}" || return 1 + ss_lock_try "${SS_REFRESHD_LOCK}.d" || return 1 + refreshd_owned=1 } refreshd_unlock() { - flock -u "${SS_REFRESHD_LOCK_FD}" 2>/dev/null || true - eval "exec ${SS_REFRESHD_LOCK_FD}>&-" 2>/dev/null || true + [ "${refreshd_owned:-0}" -eq 1 ] || return 0 + 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() { @@ -253,6 +259,6 @@ main() { } trap "handle_term" TERM INT -trap "refreshd_unlock; clear_next_refresh_at" EXIT +trap "refreshd_cleanup" EXIT main "$@" diff --git a/safeshield/files/usr/libexec/safeshield-statsd b/safeshield/files/usr/libexec/safeshield-statsd index 2a4692e2c..a51dff9e4 100755 --- a/safeshield/files/usr/libexec/safeshield-statsd +++ b/safeshield/files/usr/libexec/safeshield-statsd @@ -7,6 +7,7 @@ LC_ALL=C export LC_ALL 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_CORE_LIB="${SS_STATSD_CORE_LIB:-/usr/lib/safeshield/core.sh}" 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" # shellcheck disable=SC1090 . "$SS_STATSD_CORE_LIB" +# shellcheck disable=SC1090 +. "$SS_STATSD_LOCK_LIB" stats_poller_pid='' stats_awk_pid='' @@ -74,7 +77,8 @@ stats_cleanup() { [ -n "$stats_fifo" ] && rm -f "$stats_fifo" 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 } @@ -85,30 +89,12 @@ stats_terminate() { } stats_acquire_lock() { - local owner_pid='' - stats_lock_dir="${SS_STATISTICS_DIR}/collector.lock" - - if mkdir "$stats_lock_dir" 2>/dev/null; then - stats_lock_owned=1 - printf '%s\n' "$$" >"${stats_lock_dir}/pid" - return 0 - fi - - if [ -r "${stats_lock_dir}/pid" ]; then - owner_pid="$(cat "${stats_lock_dir}/pid" 2>/dev/null || true)" - fi - - if stats_process_running "$owner_pid"; then - log_warn "Statistics collector is already running (pid=${owner_pid})" + if ! ss_lock_try "$stats_lock_dir"; then + log_warn 'Statistics collector lock is already held or unavailable' return 1 fi - - log_warn 'Removing stale statistics collector lock' - rm -rf "$stats_lock_dir" || return 1 - mkdir "$stats_lock_dir" || return 1 stats_lock_owned=1 - printf '%s\n' "$$" >"${stats_lock_dir}/pid" return 0 } diff --git a/safeshield/files/usr/share/rpcd/acl.d/safeshield.json b/safeshield/files/usr/share/rpcd/acl.d/safeshield.json index 17a3e7463..6e294f92d 100644 --- a/safeshield/files/usr/share/rpcd/acl.d/safeshield.json +++ b/safeshield/files/usr/share/rpcd/acl.d/safeshield.json @@ -7,6 +7,7 @@ "status", "config", "statistics", + "statistics_summary", "rules_list" ] } diff --git a/safeshield/files/usr/share/rpcd/ucode/safeshield.uc b/safeshield/files/usr/share/rpcd/ucode/safeshield.uc index 44df5b66e..7426ad2fc 100644 --- a/safeshield/files/usr/share/rpcd/ucode/safeshield.uc +++ b/safeshield/files/usr/share/rpcd/ucode/safeshield.uc @@ -24,6 +24,10 @@ return { call: statistics.build }, + statistics_summary: { + call: statistics.summary + }, + config_update: { args: { values: {} diff --git a/safeshield/files/usr/share/rpcd/ucode/safeshield/statistics.uc b/safeshield/files/usr/share/rpcd/ucode/safeshield/statistics.uc index 3fc31484a..9b8948716 100644 --- a/safeshield/files/usr/share/rpcd/ucode/safeshield/statistics.uc +++ b/safeshield/files/usr/share/rpcd/ucode/safeshield/statistics.uc @@ -84,7 +84,7 @@ function sanitize_source(item) { }; } -function build_statistics() { +function build_statistics(include_detail) { reload_uci(); let enabled = to_bool(cfg('statistics_enabled', '1'), true); let retention_hours = to_int(cfg('statistics_retention_hours', '168'), 168); @@ -98,7 +98,7 @@ function build_statistics() { if (!effective_enabled) { source.available = false; } - return { + let result = { schema: { name: STATISTICS_SCHEMA_NAME, version: STATISTICS_SCHEMA_VERSION @@ -133,13 +133,26 @@ function build_statistics() { totals: { queries: to_int(totals.queries, 0), blocked: to_int(totals.blocked, 0) - }, - hourly: sanitize_hourly(data.hourly), - device_limit: to_int(data.device_limit, 128), - devices_truncated: to_bool(data.devices_truncated, false), - devices: sanitize_devices(data.devices) + } }; + // 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 { - build: build_statistics + build: build_statistics, + summary: build_statistics_summary }; diff --git a/safeshield/spec/blocklist_spec.sh b/safeshield/spec/blocklist_spec.sh index 6e3891c4c..f5d0e9174 100755 --- a/safeshield/spec/blocklist_spec.sh +++ b/safeshield/spec/blocklist_spec.sh @@ -8,6 +8,27 @@ Describe 'SafeShield blocklist behavior' The error should equal '' 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' When call ss_case_multi_artifact The status should be success diff --git a/safeshield/spec/core_spec.sh b/safeshield/spec/core_spec.sh index b4ec61dbd..6fe940a91 100755 --- a/safeshield/spec/core_spec.sh +++ b/safeshield/spec/core_spec.sh @@ -50,6 +50,13 @@ Describe 'SafeShield core shell behavior' The error should equal '' 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' When call ss_case_legacy_license_cleanup The status should be success diff --git a/safeshield/spec/lifecycle_spec.sh b/safeshield/spec/lifecycle_spec.sh index 6c349a593..4a31e31a0 100755 --- a/safeshield/spec/lifecycle_spec.sh +++ b/safeshield/spec/lifecycle_spec.sh @@ -1,7 +1,7 @@ # shellcheck shell=sh Describe 'SafeShield lifecycle and refresh state machines' - It 'rolls back full refresh failures while preserving the active blocklist for upgrade-required responses' + It 'keeps DNS running before installation failures and rolls back post-install failures' When call ss_case_refresh_state_machine The status should be success The output should equal '' diff --git a/safeshield/spec/locking_spec.sh b/safeshield/spec/locking_spec.sh new file mode 100755 index 000000000..a8cb37135 --- /dev/null +++ b/safeshield/spec/locking_spec.sh @@ -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 diff --git a/safeshield/spec/spec_helper.sh b/safeshield/spec/spec_helper.sh index 161daae80..10bb37d9e 100755 --- a/safeshield/spec/spec_helper.sh +++ b/safeshield/spec/spec_helper.sh @@ -16,6 +16,8 @@ export SS_SPEC_ROOT # shellcheck disable=SC1091 . "$SS_SPEC_ROOT/spec/support/lifecycle_cases.sh" # shellcheck disable=SC1091 +. "$SS_SPEC_ROOT/spec/support/locking_cases.sh" +# shellcheck disable=SC1091 . "$SS_SPEC_ROOT/spec/support/blocklist_cases.sh" # shellcheck disable=SC1091 . "$SS_SPEC_ROOT/spec/support/device_api_cases.sh" diff --git a/safeshield/spec/statistics_spec.sh b/safeshield/spec/statistics_spec.sh index 52c352c54..44e6d6194 100755 --- a/safeshield/spec/statistics_spec.sh +++ b/safeshield/spec/statistics_spec.sh @@ -119,6 +119,13 @@ Describe 'SafeShield statistics behavior' The error should equal '' 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' When call ss_case_statistics_modules The status should be success diff --git a/safeshield/spec/support/blocklist_cases.sh b/safeshield/spec/support/blocklist_cases.sh index a55e79897..91865a5d6 100755 --- a/safeshield/spec/support/blocklist_cases.sh +++ b/safeshield/spec/support/blocklist_cases.sh @@ -140,6 +140,203 @@ sample-two.example' 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() ( set -eu TMP_DIR="$(ss_spec_tmpdir)" diff --git a/safeshield/spec/support/core_cases.sh b/safeshield/spec/support/core_cases.sh index 6487614c7..fcb6e3114 100755 --- a/safeshield/spec/support/core_cases.sh +++ b/safeshield/spec/support/core_cases.sh @@ -6,6 +6,7 @@ ss_case_utils() ( PKG_NAME='safeshield-test' export PKG_NAME # shellcheck disable=SC1091 + SS_LOCKING_LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh" command_exists sh @@ -25,6 +26,7 @@ ss_case_config_validation() ( PKG_NAME='safeshield-test' export PKG_NAME # shellcheck disable=SC1091 + SS_LOCKING_LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh" WARNINGS='' @@ -121,6 +123,7 @@ ss_case_dnsmasq_version() ( PKG_NAME='safeshield' export PKG_NAME # shellcheck disable=SC1091 + SS_LOCKING_LIB="$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" . "$SS_SPEC_ROOT/files/usr/lib/safeshield/utils.sh" DNSMASQ_FEATURE_HEALTH='' ss_status_set() { @@ -378,6 +381,15 @@ ss_case_status_state() ( record() { printf '%s\t%s\t%s\n' "$1" "${2:-}" "${3:-}" >>"$CALLS"; } ss_status_set() { record set "$1" "$2"; } ss_status_add_error() { record error "$1"; } + # 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() { record apply "$1" "${2:-}" return 0 @@ -388,6 +400,7 @@ ss_case_status_state() ( [ "$1" -ge 0 ] 2>/dev/null } 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\tlast_failure\t1000')" "$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 : >"$CALLS" 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 grep -F "$(printf 'set\t%s\t' "$key")" "$CALLS" >/dev/null 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/files/usr/share/rpcd/ucode/safeshield/core.uc" 'fs.readfile(`/usr/lib/${PKG_NAME}/version`)' ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/share/rpcd/ucode/safeshield/status.uc" 'version: PKG_VERSION,' - ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'safeshield_upgrade_required' ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'failure_code="$(ss_resolve_error_code)"' ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'failure_code="$(ss_artifact_download_error_code)"' - ss_spec_assert_file_contains "$SS_SPEC_ROOT/files/usr/lib/safeshield/core.sh" 'if [ "$failure_code" != "safeshield_upgrade_required" ]; then' + 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' ) diff --git a/safeshield/spec/support/lifecycle_cases.sh b/safeshield/spec/support/lifecycle_cases.sh index 5a47d5a3a..6e0ee8a09 100755 --- a/safeshield/spec/support/lifecycle_cases.sh +++ b/safeshield/spec/support/lifecycle_cases.sh @@ -138,6 +138,7 @@ ss_case_refresh_state_machine() ( ss_spec_assert_file_line "$CALLS" 'restore_and_restart' else ! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null + ! grep -F 'dnsmasq_restart ' "$CALLS" >/dev/null fi } @@ -157,7 +158,10 @@ ss_case_refresh_state_machine() ( ss_spec_assert_file_line "$CALLS" 'lock_close' ! grep -Fx 'restore_and_restart' "$CALLS" >/dev/null - assert_refresh_failure merge merge_failed 1 + assert_refresh_failure 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 restart dnsmasq_restart_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' ;; esac 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' done @@ -333,54 +342,42 @@ ss_case_refresh_locking() ( set -eu TMP="$(ss_spec_tmpdir)" trap 'rm -rf "$TMP"' EXIT HUP INT TERM - RUNNING_STATUS_FILE="$TMP/status.json" SS_REFRESH_LOCK="$TMP/safeshield-refresh.lock" - SS_REFRESH_LOCK_FD=9 - MOCK_FLOCK_DIR="$TMP/mock-flock.lock" - - # Keep this regression test portable on development hosts such as macOS, - # where flock(1) is not installed by default. The production code still - # invokes flock -n/-u; this shim emulates those operations with atomic - # mkdir/rmdir so contention, release, timeout, and termination paths are - # exercised without depending on a host-specific flock binary. - flock() { - case "${1:-}" in - -n) - mkdir "$MOCK_FLOCK_DIR" 2>/dev/null - ;; - -u) - rmdir "$MOCK_FLOCK_DIR" 2>/dev/null || true - return 0 - ;; - *) - return 2 - ;; - esac - } - - # shellcheck disable=SC1091 + # shellcheck disable=SC1090 + . "$SS_SPEC_ROOT/files/usr/lib/safeshield/locking.sh" + # shellcheck disable=SC1090 . "$SS_SPEC_ROOT/files/usr/lib/safeshield/status.sh" is_valid_integer() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac } ss_should_stop() { return 1; } - mkdir "$MOCK_FLOCK_DIR" + ss_refresh_lock_open + ss_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_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; } + mkdir "${SS_REFRESH_LOCK}.d" + printf '%s\n' "$$" >"${SS_REFRESH_LOCK}.d/pid" set +e ss_refresh_lock_open_wait 10 rc=$? set -e - rmdir "$MOCK_FLOCK_DIR" ss_spec_assert_eq "$rc" '130' + ss_refresh_lock_close ) ss_case_service_lifecycle() ( diff --git a/safeshield/spec/support/locking_cases.sh b/safeshield/spec/support/locking_cases.sh new file mode 100755 index 000000000..084bf721a --- /dev/null +++ b/safeshield/spec/support/locking_cases.sh @@ -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" ] +) diff --git a/safeshield/spec/support/runtime_cases.sh b/safeshield/spec/support/runtime_cases.sh index a2774be3d..4a42f122e 100755 --- a/safeshield/spec/support/runtime_cases.sh +++ b/safeshield/spec/support/runtime_cases.sh @@ -115,6 +115,7 @@ EOF_IP PATH="$TMP/bin:$PATH" \ SS_STATSD_FUNCTIONS_LIB="$TMP/functions.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_POLL_COMMAND="$TMP/bin/stats-poll" \ SS_STATSD_GENERATION_ID='runtime-generation' \ @@ -580,6 +581,7 @@ EOF_CORE write_payload "$SS_STATISTICS_UPLOAD_JSON_FILE" 7 upload_pending_create ss_spec_assert_file_contains "$SS_STATISTICS_UPLOAD_PENDING_META_FILE" "$(printf 'recent\tgeneration-upload\t7')" + ss_should_terminate=0 MOCK_HTTP_MODE='stale' export MOCK_HTTP_MODE upload_send_pending diff --git a/safeshield/spec/support/statistics_cases.sh b/safeshield/spec/support/statistics_cases.sh index af5a17495..74ece4215 100755 --- a/safeshield/spec/support/statistics_cases.sh +++ b/safeshield/spec/support/statistics_cases.sh @@ -1272,3 +1272,37 @@ DATA ! 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")" ) + +# 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')" +) diff --git a/safeshield/spec/support/tooling_cases.sh b/safeshield/spec/support/tooling_cases.sh index a297e3805..73ef113b4 100755 --- a/safeshield/spec/support/tooling_cases.sh +++ b/safeshield/spec/support/tooling_cases.sh @@ -12,6 +12,8 @@ ss_case_tooling() ( [ -f "$SS_SPEC_ROOT/spec/statistics_spec.sh" ] [ -f "$SS_SPEC_ROOT/spec/ucode_spec.sh" ] [ -f "$SS_SPEC_ROOT/spec/tooling_spec.sh" ] + [ -f "$SS_SPEC_ROOT/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/.github/workflows/lint-shell.yml" ] ss_spec_assert_file_contains "$SS_SPEC_ROOT/.pre-commit-config.yaml" 'entry: sh scripts/lint.sh' diff --git a/safeshield/spec/support/ucode_cases.sh b/safeshield/spec/support/ucode_cases.sh index 96e607fa5..f67f5aa47 100755 --- a/safeshield/spec/support/ucode_cases.sh +++ b/safeshield/spec/support/ucode_cases.sh @@ -48,6 +48,21 @@ ss_case_ucode() ( out="$("$UCODE_BIN" "$STATUS_STORE" "$status_file" set stage running)" printf '%s\n' "$out" >"$status_file" printf '%s\n' "$out" | grep -F '"stage":"running"' >/dev/null + # 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)" printf '%s\n' "$out" >"$status_file" printf '%s\n' "$out" | grep -F '"code":"dnsmasq_warning"' >/dev/null diff --git a/safeshield/tests/ucode/test_statistics.uc b/safeshield/tests/ucode/test_statistics.uc index 1a73a5f5f..702f727ce 100644 --- a/safeshield/tests/ucode/test_statistics.uc +++ b/safeshield/tests/ucode/test_statistics.uc @@ -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'); 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');