From: Stan Grishin Date: Mon, 15 Jun 2026 01:45:37 +0000 (+0000) Subject: luci-app-adblock-fast: update to 1.2.4-2 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=f67461fd0255bf992b2b559c22ec288067f2f9da;p=openwrt-luci.git luci-app-adblock-fast: update to 1.2.4-2 Maintainer: me Compile tested: x86_64, Dell EMC Edge620, OpenWrt 25.12.4 Run tested: x86_64, Dell EMC Edge620, OpenWrt 25.12.4 Description: Update to 1.2.4-2 - Update PKG_VERSION to 1.2.4 and PKG_RELEASE to 2. README.md: - Update documentation URL from melmac.ca to mossdef.org. htdocs/luci-static/resources/adblock-fast/status.js: - Update `pkg.LuciCompat` to 17. - Add `pkg.ChromeExtensionId` for the companion browser extension. - Update documentation URLs from melmac.ca to mossdef.org. - Add new warning messages for throttled parallel downloads and download timeouts. - Update `errorDetectingFileType` message to include the filename. - Update `syncCron` RPC: it now accepts schedule parameters (e.g., `auto_update_enabled`, `auto_update_mode`) as distinct arguments instead of relying on UCI. The cron line is assembled and validated server-side. - Remove `setCronEntry` RPC, replaced by the extended `syncCron`. - Wrap `_syncCron` with a `syncCron` helper function that translates the schedule object into discrete arguments for the RPC call. htdocs/luci-static/resources/view/adblock-fast/overview.js: - Add `maybeNagChromeExtension` to detect the AdBlock-Fast Controller Chrome extension and nag if missing. - Remove `generateCronEntry` helper function: the cron line is now assembled server-side. - Mark auto_update_* form fields as virtual: they are displayed from parsed cron data and submitted via `syncCron`, but never written to UCI. - Add new advanced options for download: `download_connect_timeout`, `download_max_time`, and `download_allow_insecure`. - Update `parallel_downloads` to be a range (1-16) instead of a boolean flag, with a default of 8. The label now indicates automatic reduction with low memory. - Modify `handleSave` and `handleSaveApply` to collect schedule data and pass it to `adb.syncCron` as discrete fields, bypassing UCI writes for scheduling. This ensures the crontab is the single source of truth for the schedule. root/etc/uci-defaults/40_luci-adblock-fast: - Add logic to remove stale `auto_update_*` UCI keys from older versions, as the cron schedule is no longer mirrored in UCI. root/usr/share/rpcd/acl.d/luci-app-adblock-fast.json: - Remove `setCronEntry` from ACL, as it has been replaced by `syncCron`. root/usr/share/rpcd/ucode/luci.adblock-fast: - Update `rpcdCompat` to 17. - Remove explicit `setCronEntry` call from the example section. - Update `syncCron` example: it now includes schedule parameters, replacing `setCronEntry`. - Add `cron_field` helper function to coerce schedule fields to bounded integers, providing security against injection into crontabs. - Add `CRON_MODES` constant to allowlist accepted scheduling modes. - Update `cron_write` to accept `schedule` object with discrete fields. It now parses existing cron lines to preserve the schedule on state-only changes and validates all fields through `cron_field`/`CRON_MODES`. - Rename `get_cron_status` to `cron_read`, reflecting its read-only nature. - Update `cron_read` to parse the crontab line and return the schedule as strings, eliminating reliance on UCI for schedule configuration. The UI now parses this entry for display. - Refactor cron management functions (`update_cron`, `get_cron_status`) for improved security, robustness, and to remove UCI as a source of truth for cron schedule. Signed-off-by: Stan Grishin --- diff --git a/applications/luci-app-adblock-fast/Makefile b/applications/luci-app-adblock-fast/Makefile index fd596281d8..4318feb735 100644 --- a/applications/luci-app-adblock-fast/Makefile +++ b/applications/luci-app-adblock-fast/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-adblock-fast PKG_LICENSE:=AGPL-3.0-or-later PKG_MAINTAINER:=Stan Grishin -PKG_VERSION:=1.2.2 -PKG_RELEASE:=18 +PKG_VERSION:=1.2.4 +PKG_RELEASE:=2 LUCI_TITLE:=AdBlock-Fast Web UI LUCI_URL:=https://github.com/mossdef-org/luci-app-adblock-fast/ diff --git a/applications/luci-app-adblock-fast/README.md b/applications/luci-app-adblock-fast/README.md index 44ef1f8710..5044912c5c 100644 --- a/applications/luci-app-adblock-fast/README.md +++ b/applications/luci-app-adblock-fast/README.md @@ -1,7 +1,7 @@ # luci-app-adblock-fast [![OpenWrt](https://img.shields.io/badge/OpenWrt-Compatible-blueviolet)](https://openwrt.org) -[![Web UI](https://img.shields.io/badge/Web_UI-Available-blue)](https://docs.openwrt.melmac.ca/adblock-fast/) +[![Web UI](https://img.shields.io/badge/Web_UI-Available-blue)](https://docs.mossdef.org/adblock-fast/) [![Lightweight](https://img.shields.io/badge/Size-Lightweight-brightgreen)](https://openwrt.org/packages/pkgdata/adblock-fast) [![License](https://img.shields.io/badge/License-AGPL--3.0--or--later-lightgrey)](https://github.com/stangri/adblock-fast/blob/master/LICENSE) @@ -17,4 +17,4 @@ It runs once to process and install blocklists, then exits — keeping memory us - Reverts if DNS resolution fails after restart 📚 **Full documentation:** -[https://docs.openwrt.melmac.ca/adblock-fast/](https://docs.openwrt.melmac.ca/adblock-fast/) +[https://docs.mossdef.org/adblock-fast/](https://docs.mossdef.org/adblock-fast/) diff --git a/applications/luci-app-adblock-fast/htdocs/luci-static/resources/adblock-fast/status.js b/applications/luci-app-adblock-fast/htdocs/luci-static/resources/adblock-fast/status.js index 1bc6cee6cf..71003aac96 100644 --- a/applications/luci-app-adblock-fast/htdocs/luci-static/resources/adblock-fast/status.js +++ b/applications/luci-app-adblock-fast/htdocs/luci-static/resources/adblock-fast/status.js @@ -12,14 +12,17 @@ var pkg = { return "adblock-fast"; }, get LuciCompat() { - return 14; + return 17; + }, + get ChromeExtensionId() { + return "klkdabjeohlmbcnidbealmacfjlihopo"; }, get ReadmeCompat() { return ""; }, get URL() { return ( - "https://docs.openwrt.melmac.ca/" + + "https://docs.mossdef.org/" + pkg.Name + "/" + (pkg.ReadmeCompat ? pkg.ReadmeCompat + "/" : "") @@ -27,7 +30,7 @@ var pkg = { }, get DonateURL() { return ( - "https://docs.openwrt.melmac.ca/" + + "https://docs.mossdef.org/" + pkg.Name + "/" + (pkg.ReadmeCompat ? pkg.ReadmeCompat + "/" : "") + @@ -107,6 +110,12 @@ var pkg = { warningCronMissing: _( "Cron daemon is not available. If BusyBox crond is present, enable it with: %s; otherwise install another cron daemon.", ), + warningParallelDownloadsThrottled: _( + "Parallel downloads reduced to %s due to low free memory", + ), + warningDownloadTimeout: _( + "Download of '%s' timed out; the server may be too slow — consider increasing download_timeout, download_connect_timeout or download_max_time", + ), }, errorTable: { @@ -155,7 +164,7 @@ var pkg = { errorCreatingDirectory: _( "Failed to create output/cache/gzip file directory", ), - errorDetectingFileType: _("Failed to detect format %s"), + errorDetectingFileType: _("Failed to detect format for %s"), errorNothingToDo: _("No blocked list URLs nor blocked-domains enabled"), errorTooLittleRam: _( "Free ram (%s) is not enough to process all enabled block-lists", @@ -176,13 +185,31 @@ var getFileUrlFilesizes = rpc.declare({ params: ["name", "url"], }); -var syncCron = rpc.declare({ +var _syncCron = rpc.declare({ object: "luci." + pkg.Name, method: "syncCron", - params: ["name", "action"], + params: [ + "name", "action", + "auto_update_enabled", "auto_update_mode", "auto_update_minute", + "auto_update_hour", "auto_update_weekday", "auto_update_monthday", + "auto_update_every_ndays", "auto_update_every_nhours", + ], expect: { result: false }, }); +// syncCron(name, action, schedule?) — the schedule object's auto_update_* +// fields are passed as discrete, server-validated args (no cron-line string, +// no UCI write). Omit schedule for a state-only change (preserves existing). +function syncCron(name, action, schedule) { + var s = schedule || {}; + return _syncCron( + name, action, + s.auto_update_enabled, s.auto_update_mode, s.auto_update_minute, + s.auto_update_hour, s.auto_update_weekday, s.auto_update_monthday, + s.auto_update_every_ndays, s.auto_update_every_nhours, + ); +} + var getInitList = rpc.declare({ object: "luci." + pkg.Name, method: "getInitList", @@ -201,13 +228,6 @@ var getCronStatus = rpc.declare({ params: ["name"], }); -var setCronEntry = rpc.declare({ - object: "luci." + pkg.Name, - method: "setCronEntry", - params: ["name", "entry"], - expect: { result: false }, -}); - var getPlatformSupport = rpc.declare({ object: "luci." + pkg.Name, method: "getPlatformSupport", @@ -831,7 +851,6 @@ return L.Class.extend({ getFileUrlFilesizes: getFileUrlFilesizes, syncCron: syncCron, getCronStatus: getCronStatus, - setCronEntry: setCronEntry, getPlatformSupport: getPlatformSupport, getServiceInfo: getServiceInfo, getQueryLogStatus: getQueryLogStatus, diff --git a/applications/luci-app-adblock-fast/htdocs/luci-static/resources/view/adblock-fast/overview.js b/applications/luci-app-adblock-fast/htdocs/luci-static/resources/view/adblock-fast/overview.js index fcc5768e40..1fcb782911 100644 --- a/applications/luci-app-adblock-fast/htdocs/luci-static/resources/view/adblock-fast/overview.js +++ b/applications/luci-app-adblock-fast/htdocs/luci-static/resources/view/adblock-fast/overview.js @@ -13,6 +13,36 @@ var pkg = adb.pkg; return view.extend({ + // Detect the AdBlock-Fast Controller Chrome extension; nag if missing on + // browsers that can install Chrome Web Store extensions. + maybeNagChromeExtension: function () { + var ua = navigator.userAgentData; + if (!ua || ua.mobile) return; + var supportsChromeStore = ua.brands && ua.brands.some(function (b) { + return /^(Google Chrome|Microsoft Edge|Chromium|Brave|Opera|Vivaldi)$/.test(b.brand); + }); + if (!supportsChromeStore) return; + + var self = this; + fetch("chrome-extension://" + pkg.ChromeExtensionId + "/info.json") + .then(function (r) { return r.ok ? r.json() : Promise.reject(); }) + .then(function (info) { + self.chromeExtensionInfo = info; + }) + .catch(function () { + ui.addNotification( + null, + E("p", {}, + _("Tip: install the %sAdBlock-Fast Controller Chrome extension%s to control this router from your browser toolbar.").format( + '', + "" + ) + ), + "info" + ); + }); + }, + // Helper function to parse cron entry into config values parseCronEntry: function (cronEntry) { var defaults = { @@ -106,50 +136,6 @@ return view.extend({ return config; }, - // Helper function to generate cron entry from config values - generateCronEntry: function (config) { - if (config.auto_update_enabled !== "1") { - return ""; - } - - var minute = config.auto_update_minute || "0"; - var hour, - dom = "*", - dow = "*"; - - switch (config.auto_update_mode) { - case "every_n_hours": - hour = "*/" + (config.auto_update_every_nhours || "6"); - break; - case "every_n_days": - hour = config.auto_update_hour || "4"; - dom = "*/" + (config.auto_update_every_ndays || "3"); - break; - case "monthly": - hour = config.auto_update_hour || "4"; - dom = config.auto_update_monthday || "1"; - break; - case "weekly": - hour = config.auto_update_hour || "4"; - dow = config.auto_update_weekday || "0"; - break; - default: // daily - hour = config.auto_update_hour || "4"; - break; - } - - return ( - minute + - " " + - hour + - " " + - dom + - " * " + - dow + - " /etc/init.d/adblock-fast dl # adblock-fast-auto" - ); - }, - load: function () { return Promise.all([ L.resolveDefault(adb.getInitStatus(pkg.Name), {}), @@ -162,6 +148,8 @@ return view.extend({ }, render: function (data) { + this.maybeNagChromeExtension(); + var initData = (data[0] && data[0][pkg.Name]) || {}; var reply = { sizes: initData.file_url || [], @@ -520,7 +508,9 @@ return view.extend({ o.value("0", _("Disable")); o.value("1", _("Enable")); o.default = "0"; - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_enabled; }; @@ -539,7 +529,9 @@ return view.extend({ o.value("every_n_hours", _("Every N hours")); o.default = "daily"; o.depends("auto_update_enabled", "1"); - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_mode; }; @@ -556,7 +548,9 @@ return view.extend({ } o.default = "3"; o.depends({ auto_update_enabled: "1", auto_update_mode: "every_n_days" }); - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_every_ndays; }; @@ -573,7 +567,9 @@ return view.extend({ } o.default = "6"; o.depends({ auto_update_enabled: "1", auto_update_mode: "every_n_hours" }); - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_every_nhours; }; @@ -594,7 +590,9 @@ return view.extend({ o.value("6", _("Saturday")); o.default = "0"; o.depends({ auto_update_enabled: "1", auto_update_mode: "weekly" }); - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_weekday; }; @@ -611,7 +609,9 @@ return view.extend({ } o.default = "1"; o.depends({ auto_update_enabled: "1", auto_update_mode: "monthly" }); - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_monthday; }; @@ -632,7 +632,9 @@ return view.extend({ o.depends({ auto_update_enabled: "1", auto_update_mode: "weekly" }); o.depends({ auto_update_enabled: "1", auto_update_mode: "monthly" }); o.depends({ auto_update_enabled: "1", auto_update_mode: "every_n_days" }); - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_hour; }; @@ -652,7 +654,9 @@ return view.extend({ } o.default = "0"; o.depends("auto_update_enabled", "1"); - // Override to use cron data instead of UCI + // Virtual field: displayed from parsed cron data and submitted to the + // backend via syncCron (see handleSaveApply); never written to UCI. + o.write = o.remove = function () {}; o.cfgvalue = function (section_id) { return cronConfig.auto_update_minute; }; @@ -693,6 +697,42 @@ return view.extend({ o.default = "20"; o.datatype = "range(1,60)"; + o = s1.taboption( + "tab_advanced", + form.Value, + "download_connect_timeout", + _("Connect time-out (in seconds)"), + _( + "Stop the download if the connection cannot be established within this many seconds. Supported by curl and GNU wget only.", + ), + ); + o.default = "10"; + o.datatype = "range(1,60)"; + + o = s1.taboption( + "tab_advanced", + form.Value, + "download_max_time", + _("Maximum download time (in seconds)"), + _( + "Abort the download if the whole transfer takes longer than this many seconds, even if it is still progressing. Leave empty to disable. Currently implemented for curl only.", + ), + ); + o.default = ""; + o.datatype = "uinteger"; + o.rmempty = true; + + o = s1.taboption( + "tab_advanced", + form.Flag, + "download_allow_insecure", + _("Allow insecure downloads"), + _( + "Skip SSL certificate verification when downloading block-lists. Enabled by default for compatibility with self-signed or otherwise untrusted certificates.", + ), + ); + o.default = "1"; + o = s1.taboption( "tab_advanced", form.Value, @@ -736,12 +776,12 @@ return view.extend({ "parallel_downloads", _("Simultaneous processing"), _( - "Launch all lists downloads and processing simultaneously, reducing service start time.", + "Maximum number of block lists to download and process at the same time (0 disables). Automatically reduced when free memory is low.", ), ); - o.value("0", _("Do not use simultaneous processing")); - o.value("1", _("Use simultaneous processing")); - o.default = "1"; + o.value("0", _("Disabled")); + for (var i = 1; i <= 16; i++) o.value(String(i)); + o.default = "8"; o = s1.taboption( "tab_advanced", @@ -1076,67 +1116,51 @@ return view.extend({ }); }, - handleSave: function (ev) { - var map = this._map; - if (!map) { - return this.super("handleSave", [ev]); - } - - // Collect virtual scheduling values - var schedulingConfig = {}; - var schedulingFields = [ - "auto_update_enabled", - "auto_update_mode", - "auto_update_hour", - "auto_update_minute", - "auto_update_weekday", - "auto_update_monthday", - "auto_update_every_ndays", - "auto_update_every_nhours", - ]; - - schedulingFields.forEach(function (fieldName) { - var match = map.lookupOption(fieldName, "config"); - if (match && match[0].isValid("config")) { - schedulingConfig[fieldName] = match[0].formvalue("config"); - } - }); - - // Generate cron entry from config - var cronEntry = this.generateCronEntry(schedulingConfig); - - // Save cron entry directly - var savePromise = L.resolveDefault(adb.setCronEntry(pkg.Name, cronEntry), { - result: false, - }).then(function (result) { - if (!result || result.result === false) { - ui.addNotification( - null, - E("p", {}, _("Failed to update cron schedule.")), - ); - return Promise.reject(new Error("Failed to update cron schedule")); - } - - // Remove scheduling values from UCI before saving - schedulingFields.forEach(function (fieldName) { - var match = map.lookupOption(fieldName, "config"); - if (match) { - match[0].remove("config"); - } - }); - - // Save the rest of UCI config - return Promise.resolve(); - }); - - return savePromise.then(() => { - return this.super("handleSave", [ev]); + // The schedule (auto_update_*) is NOT stored in UCI — the crontab is its + // sole source of truth. On apply we collect the schedule from the form and + // send it as discrete, server-validated fields to syncCron, which renders + // the crontab. No cron-line string is built in the browser, and no schedule + // write touches UCI (so a schedule change never triggers an adbf reload). + + collectSchedule: function (map) { + var schedule = {}; + [ + "auto_update_enabled", "auto_update_mode", + "auto_update_hour", "auto_update_minute", + "auto_update_weekday", "auto_update_monthday", + "auto_update_every_ndays", "auto_update_every_nhours", + ].forEach(function (field) { + var opt = map ? map.lookupOption(field, "config") : null; + // Hidden (mode-irrelevant) fields return null and are omitted; the + // backend supplies its matching defaults for those. + var val = opt && opt[0] ? opt[0].formvalue("config") : null; + if (val != null && val !== "") + schedule[field] = val; }); + // The backend treats auto_update_enabled as the marker of an explicit + // schedule update, so always include it. + if (schedule.auto_update_enabled == null) + schedule.auto_update_enabled = "0"; + return schedule; }, handleSaveApply: function (ev, mode) { - return this.handleSave(ev).then(function () { - return ui.changes.apply(mode == "0"); - }); + var self = this, map = this._map; + return this.handleSave(ev) + .then(function () { + return ui.changes.apply(mode == "0"); + }) + .then(function () { + return L.resolveDefault( + adb.syncCron(pkg.Name, null, self.collectSchedule(map)), + false, + ).then(function (result) { + if (result === false) + ui.addNotification( + null, + E("p", {}, _("Failed to update cron schedule.")), + ); + }); + }); }, }); diff --git a/applications/luci-app-adblock-fast/root/etc/uci-defaults/40_luci-adblock-fast b/applications/luci-app-adblock-fast/root/etc/uci-defaults/40_luci-adblock-fast index 080086891a..ef86c35544 100644 --- a/applications/luci-app-adblock-fast/root/etc/uci-defaults/40_luci-adblock-fast +++ b/applications/luci-app-adblock-fast/root/etc/uci-defaults/40_luci-adblock-fast @@ -1,4 +1,18 @@ #!/bin/sh rm -rf /var/luci-modulecache/; rm -f /var/luci-indexcache; + +# The cron schedule is no longer mirrored into UCI (the crontab is its sole +# source of truth). Drop any stale auto_update_* keys left by older versions +# so they don't linger in the config; they are unused and inert otherwise. +adbf_changed=0 +for opt in auto_update_enabled auto_update_mode auto_update_minute \ + auto_update_hour auto_update_weekday auto_update_monthday \ + auto_update_every_ndays auto_update_every_nhours; do + if [ -n "$(uci -q get "adblock-fast.config.${opt}")" ]; then + uci -q delete "adblock-fast.config.${opt}" && adbf_changed=1 + fi +done +[ "$adbf_changed" = 1 ] && uci commit adblock-fast + [ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload; exit 0 diff --git a/applications/luci-app-adblock-fast/root/usr/share/rpcd/acl.d/luci-app-adblock-fast.json b/applications/luci-app-adblock-fast/root/usr/share/rpcd/acl.d/luci-app-adblock-fast.json index 211623d834..ffcb65b5e8 100644 --- a/applications/luci-app-adblock-fast/root/usr/share/rpcd/acl.d/luci-app-adblock-fast.json +++ b/applications/luci-app-adblock-fast/root/usr/share/rpcd/acl.d/luci-app-adblock-fast.json @@ -40,7 +40,6 @@ "ubus": { "luci.adblock-fast": [ "syncCron", - "setCronEntry", "setInitAction", "setRpcdToken", "setQueryLog" diff --git a/applications/luci-app-adblock-fast/root/usr/share/rpcd/ucode/luci.adblock-fast b/applications/luci-app-adblock-fast/root/usr/share/rpcd/ucode/luci.adblock-fast index 470c149d46..f706281445 100644 --- a/applications/luci-app-adblock-fast/root/usr/share/rpcd/ucode/luci.adblock-fast +++ b/applications/luci-app-adblock-fast/root/usr/share/rpcd/ucode/luci.adblock-fast @@ -11,9 +11,9 @@ ubus call luci.adblock-fast getPlatformSupport '{"name":"adblock-fast"}' ubus call luci.adblock-fast getCronStatus '{"name":"adblock-fast"}' ubus call luci.adblock-fast getCronEntry '{"name":"adblock-fast"}' ubus call luci.adblock-fast getFileUrlFilesizes '{"name":"adblock-fast"}' -ubus call luci.adblock-fast setCronEntry '{"name":"adblock-fast","entry":"0 4 * * * /etc/init.d/adblock-fast dl"}' ubus call luci.adblock-fast setInitAction '{"name":"adblock-fast","action":"start"}' -ubus call luci.adblock-fast syncCron '{"name":"adblock-fast","action":"start"}' +ubus call luci.adblock-fast syncCron '{"name":"adblock-fast","auto_update_enabled":"1","auto_update_mode":"daily","auto_update_hour":"4","auto_update_minute":"0"}' # set schedule (validated server-side, replaces setCronEntry) +ubus call luci.adblock-fast syncCron '{"name":"adblock-fast","action":"stop"}' # state-only change, preserves existing schedule ubus call luci.adblock-fast setRpcdToken '{"name":"adblock-fast","token":"newtoken"}' ubus call luci.adblock-fast getQueryLogStatus '{"name":"adblock-fast"}' ubus call luci.adblock-fast setQueryLog '{"name":"adblock-fast","action":"enable"}' @@ -24,7 +24,7 @@ import { readfile, writefile, stat, rename, unlink, chmod, mkdir, access } from import { cursor } from 'uci'; const packageName = 'adblock-fast'; -const rpcdCompat = 14; // ucode-lsp disable +const rpcdCompat = 17; // ucode-lsp disable // ── Helpers ───────────────────────────────────────────────────────── @@ -38,6 +38,20 @@ function uci_bool(val) { } } +// Coerce a scheduling field to a bounded integer. Anything that is not a +// plain decimal in [lo, hi] falls back to dflt. This is the security gate +// for cron-line assembly: the returned value is an int, so no newline or +// shell metacharacter from UCI/RPC input can ever reach /etc/crontabs/root. +function cron_field(val, lo, hi, dflt) { + val = '' + (val ?? ''); + if (!match(val, /^[0-9]+$/)) return dflt; + let n = int(val); + return (n < lo || n > hi) ? dflt : n; +} + +// Allowlist of accepted scheduling modes. +const CRON_MODES = { daily: 1, weekly: 1, monthly: 1, every_n_days: 1, every_n_hours: 1 }; + // Return resolved list of selected instance section names. // null = all instances, [] = none, [...] = specific names. function get_selected_instances(uci_ctx, config, section_type, instance_option) { @@ -65,35 +79,109 @@ function get_selected_instances(uci_ctx, config, section_type, instance_option) // ── Cron Management ───────────────────────────────────────────────── -function update_cron(action) { +// Parse one adblock-fast cron line body (comment marker already stripped) into +// raw schedule fields, or null if it isn't a well-formed adblock-fast line. +// Values are returned as-is (strings); cron_write re-validates them through +// cron_field/CRON_MODES, so this never has to be trusted on its own. +function parse_cron_line(body) { + let f = split(trim(body), /\s+/); + if (length(f) < 7) return null; + if (f[3] != '*') return null; + if (f[5] != '/etc/init.d/' + packageName) return null; + if (f[6] != 'dl') return null; + if (!match(f[0], /^[0-9]+$/)) return null; + + let minute = f[0], hour = f[1], dom = f[2], dow = f[4]; + let r = { minute: minute, hour: hour }; + + if (index(hour, '/') >= 0) { + if (dom != '*' || dow != '*') return null; + let n = split(hour, '/')[1]; + if (!match(n, /^[0-9]+$/)) return null; + r.mode = 'every_n_hours'; r.nhours = n; + } else if (index(dom, '/') >= 0) { + if (dow != '*' || !match(hour, /^[0-9]+$/)) return null; + let n = split(dom, '/')[1]; + if (!match(n, /^[0-9]+$/)) return null; + r.mode = 'every_n_days'; r.ndays = n; + } else if (dom != '*') { + if (dow != '*' || !match(hour, /^[0-9]+$/) || !match(dom, /^[0-9]+$/)) return null; + r.mode = 'monthly'; r.mday = dom; + } else if (dow != '*') { + if (!match(hour, /^[0-9]+$/) || !match(dow, /^[0-9]+$/)) return null; + r.mode = 'weekly'; r.wday = dow; + } else { + if (!match(hour, /^[0-9]+$/)) return null; + r.mode = 'daily'; + } + return r; +} + +// cron_write: assemble and write the adblock-fast crontab line. +// action - optional state change ('enable'/'start' → active, +// 'disable'/'stop' → suspended; anything else → derive from +// the service 'enabled' flag). +// schedule - object of auto_update_* fields (from a validated RPC call), or +// null for a state-only change, in which case the schedule of the +// EXISTING line is preserved. +// SECURITY: this is the single validation chokepoint. Every field that gets +// interpolated — whether it came from the RPC args or from parsing the existing +// crontab line — is forced through cron_field()/CRON_MODES immediately below, +// so no input path can write a newline or shell metacharacter into the crontab +// (GHSA-ggpf-xrph-wg5v class). No schedule is read from or written to UCI. +function cron_write(action, schedule) { let cron_file = '/etc/crontabs/root'; let tmp_file = cron_file + '.tmp'; - let uci_ctx = cursor(); - uci_ctx.load(packageName); - let cfg = uci_ctx.get_all(packageName, 'config') || {}; - - // Read existing cron, filter out our lines + // Read existing cron, drop our line(s), and remember the existing schedule + // and on/off state so a state-only change can preserve them. let content = readfile(cron_file) || ''; let pattern = sprintf('/etc/init.d/%s\\s+dl', packageName); let re = regexp(pattern); let lines = []; + let existing = null, existing_enabled = false; for (let line in split(content, '\n')) { - if (!match(line, re)) - push(lines, line); + let commented = match(line, /^\s*#/) ? true : false; + let body = commented ? replace(line, /^\s*#\s*/, '') : line; + if (match(body, re)) { + let st = (index(line, 'adblock-fast-auto-disabled') >= 0) ? 'disabled' : + (index(line, 'adblock-fast-auto-suspended') >= 0) ? 'suspended' : + (commented ? 'disabled' : 'active'); + if (st == 'active' || st == 'suspended') existing_enabled = true; + let p = parse_cron_line(body); + if (p) existing = p; + continue; + } + push(lines, line); } - // Remove trailing empty element from split while (length(lines) > 0 && lines[length(lines) - 1] === '') pop(lines); - let auto_enabled = uci_bool(cfg.auto_update_enabled ?? '0'); - let mode = cfg.auto_update_mode || 'daily'; - let minute = cfg.auto_update_minute || '0'; - let hour = cfg.auto_update_hour || '4'; - let wday = cfg.auto_update_weekday || '0'; - let mday = cfg.auto_update_monthday || '1'; - let ndays = cfg.auto_update_every_ndays || '3'; - let nhours = cfg.auto_update_every_nhours || '6'; + // Choose the schedule source, then validate every field at the gate. + let src, auto_enabled; + if (schedule != null) { + src = { + mode: schedule.auto_update_mode, + minute: schedule.auto_update_minute, + hour: schedule.auto_update_hour, + wday: schedule.auto_update_weekday, + mday: schedule.auto_update_monthday, + ndays: schedule.auto_update_every_ndays, + nhours: schedule.auto_update_every_nhours, + }; + auto_enabled = uci_bool(schedule.auto_update_enabled ?? '0'); + } else { + src = existing || {}; + auto_enabled = existing_enabled; + } + + let mode = CRON_MODES[src.mode] ? src.mode : 'daily'; + let minute = cron_field(src.minute, 0, 59, 0); + let hour = cron_field(src.hour, 0, 23, 4); + let wday = cron_field(src.wday, 0, 7, 0); + let mday = cron_field(src.mday, 1, 31, 1); + let ndays = cron_field(src.ndays, 1, 31, 3); + let nhours = cron_field(src.nhours, 1, 23, 6); let dom = '*', dow = '*'; switch (mode) { @@ -115,11 +203,12 @@ function update_cron(action) { add_line = 2; break; case 'enable': case 'start': add_line = 1; break; - default: - let is_en = uci_bool(cfg.enabled ?? '0'); - add_line = is_en ? 1 : 2; + default: { + let c = cursor(); c.load(packageName); + add_line = uci_bool(c.get(packageName, 'config', 'enabled') ?? '0') ? 1 : 2; break; } + } } else { add_line = 3; } @@ -146,30 +235,27 @@ function update_cron(action) { return true; } -function get_cron_status(req) { +// cron_read: report the state of the adblock-fast crontab line. Read-only — +// it parses the crontab and returns status + the matched entry to the caller +// (the UI parses the entry for display). It does NOT write UCI: the crontab is +// the single source of truth for the schedule, so there is nothing to sync. +function cron_read(req) { let name = req.args?.name || packageName; let cron_file = '/etc/crontabs/root'; - let cron_uci = cursor(); - cron_uci.load(packageName); - - let auto_enabled = uci_bool(cron_uci.get(packageName, 'config', 'auto_update_enabled') ?? '0'); - let cron_init = !!access('/etc/init.d/cron', 'x'); let cron_bin = system('command -v crond >/dev/null 2>&1') == 0 || !!access('/usr/sbin/crond', 'x'); let cron_enabled = cron_init && system('/etc/init.d/cron enabled >/dev/null 2>&1') == 0; let cron_running = (cron_init && system('/etc/init.d/cron status >/dev/null 2>&1') == 0) || system('pidof crond >/dev/null 2>&1') == 0; - let cron_line_present = false; - let cron_line_match = false; - let cron_multi = false; - let cron_parse_ok = false; - let cron_state = 'none'; - let parsed = {}; + let cron_line_present = false, cron_line_match = false, cron_multi = false; + let cron_parse_ok = false, cron_state = 'none'; + let auto_enabled = false; + let parsed_state = ''; let line_count = 0, match_count = 0; - let active_seen = false, suspended_seen = false, disabled_seen = false; + let active_seen = false, suspended_seen = false; let last_line_state = ''; let matched_entry = ''; @@ -181,75 +267,21 @@ function get_cron_status(req) { if (!length(trim(line))) continue; let commented = match(line, /^\s*#/) ? true : false; - let line_content = commented ? replace(line, /^\s*#\s*/, '') : line; - - if (!match(line_content, re)) continue; + let body = commented ? replace(line, /^\s*#\s*/, '') : line; + if (!match(body, re)) continue; line_count++; matched_entry = line; - let state; - if (index(line, 'adblock-fast-auto-disabled') >= 0) { - state = 'disabled'; - } else if (index(line, 'adblock-fast-auto-suspended') >= 0) { - state = 'suspended'; - } else if (index(line, 'adblock-fast-auto') >= 0) { - state = commented ? 'disabled' : 'active'; - } else { - state = commented ? 'disabled' : 'active'; - } + let state = (index(line, 'adblock-fast-auto-disabled') >= 0) ? 'disabled' : + (index(line, 'adblock-fast-auto-suspended') >= 0) ? 'suspended' : + (commented ? 'disabled' : 'active'); last_line_state = state; - if (state == 'active') active_seen = true; if (state == 'suspended') suspended_seen = true; - if (state == 'disabled') disabled_seen = true; - - let fields = split(trim(line_content), /\s+/); - let p_ok = true; - - // Validate basic structure - if (length(fields) < 7) p_ok = false; - if (p_ok && fields[3] != '*') p_ok = false; - if (p_ok && fields[5] != '/etc/init.d/' + packageName) p_ok = false; - if (p_ok && fields[6] != 'dl') p_ok = false; - if (p_ok && !match(fields[0], /^[0-9]+$/)) p_ok = false; - - let p_minute = fields[0], p_hour = fields[1], p_dom = fields[2], p_dow = fields[4]; - let p_mode = '', p_ndays = '', p_nhours = ''; - - if (p_ok) { - if (index(p_hour, '/') >= 0) { - p_nhours = split(p_hour, '/')[1]; - if (!match(p_nhours, /^[0-9]+$/)) p_ok = false; - if (p_dom != '*' || p_dow != '*') p_ok = false; - p_mode = 'every_n_hours'; - } else if (index(p_dom, '/') >= 0) { - p_ndays = split(p_dom, '/')[1]; - if (!match(p_ndays, /^[0-9]+$/)) p_ok = false; - if (!match(p_hour, /^[0-9]+$/)) p_ok = false; - if (p_dow != '*') p_ok = false; - p_mode = 'every_n_days'; - } else if (p_dom != '*') { - if (!match(p_dom, /^[0-9]+$/)) p_ok = false; - if (!match(p_hour, /^[0-9]+$/)) p_ok = false; - if (p_dow != '*') p_ok = false; - p_mode = 'monthly'; - } else if (p_dow != '*') { - if (!match(p_dow, /^[0-9]+$/)) p_ok = false; - if (!match(p_hour, /^[0-9]+$/)) p_ok = false; - p_mode = 'weekly'; - } else { - if (!match(p_hour, /^[0-9]+$/)) p_ok = false; - p_mode = 'daily'; - } - } - if (p_ok) { + if (parse_cron_line(body) != null) { match_count++; - parsed = { - state: state, mode: p_mode, minute: p_minute, - hour: p_hour, dom: p_dom, dow: p_dow, - wday: p_dow, mday: p_dom, ndays: p_ndays, nhours: p_nhours, - }; + parsed_state = state; } } @@ -265,31 +297,13 @@ function get_cron_status(req) { } else if (match_count == 1) { cron_line_match = true; cron_parse_ok = true; - cron_state = parsed.state; - auto_enabled = (parsed.state == 'active' || parsed.state == 'suspended'); - - if (parsed.mode) - cron_uci.set(packageName, 'config', 'auto_update_mode', parsed.mode); - if (parsed.minute) - cron_uci.set(packageName, 'config', 'auto_update_minute', parsed.minute); - if (parsed.hour && parsed.mode != 'every_n_hours') - cron_uci.set(packageName, 'config', 'auto_update_hour', parsed.hour); - if (parsed.mode == 'weekly' && parsed.wday) - cron_uci.set(packageName, 'config', 'auto_update_weekday', parsed.wday); - if (parsed.mode == 'monthly' && parsed.mday) - cron_uci.set(packageName, 'config', 'auto_update_monthday', parsed.mday); - if (parsed.mode == 'every_n_days' && parsed.ndays) - cron_uci.set(packageName, 'config', 'auto_update_every_ndays', parsed.ndays); - if (parsed.mode == 'every_n_hours' && parsed.nhours) - cron_uci.set(packageName, 'config', 'auto_update_every_nhours', parsed.nhours); + cron_state = parsed_state; + auto_enabled = (parsed_state == 'active' || parsed_state == 'suspended'); } else { cron_state = 'unsupported'; auto_enabled = (last_line_state == 'active' || last_line_state == 'suspended'); } - cron_uci.set(packageName, 'config', 'auto_update_enabled', auto_enabled ? '1' : '0'); - if (length(cron_uci.changes(packageName))) cron_uci.commit(packageName); - let result = {}; result[name] = { auto_update_enabled: auto_enabled, @@ -329,58 +343,26 @@ function get_cron_entry(req) { return result; } -function set_cron_entry(req) { - let name = req.args?.name || packageName; - let cron_entry = req.args?.entry; - let cron_file = '/etc/crontabs/root'; - let temp_file = cron_file + '.tmp'; - let found = false, written = false; - - let dir = replace(cron_file, /\/[^\/]+$/, ''); - if (!stat(dir)) mkdir(dir); - - let content = readfile(cron_file) || ''; - let pattern = sprintf('/etc/init.d/%s\\s+dl', packageName); - let re = regexp(pattern); - let out_lines = []; - - for (let line in split(content, '\n')) { - if (match(line, re)) { - if (!written && cron_entry) { - push(out_lines, cron_entry); - written = true; - } - found = true; - } else { - push(out_lines, line); - } - } - - if (!found && cron_entry) - push(out_lines, cron_entry); - - writefile(temp_file, join('\n', out_lines) + '\n'); - - if (rename(temp_file, cron_file)) { - chmod(cron_file, 0600); - if (access('/etc/init.d/cron', 'x')) { - if (system('/etc/init.d/cron enabled >/dev/null 2>&1') == 0) - system('/etc/init.d/cron restart >/dev/null 2>&1'); - else if (system('pidof crond >/dev/null 2>&1') == 0) - system('killall -HUP crond 2>/dev/null'); - } else if (system('pidof crond >/dev/null 2>&1') == 0) { - system('killall -HUP crond 2>/dev/null'); - } - return { result: true }; - } - unlink(temp_file); - return { result: false }; -} +// NOTE: the former set_cron_entry() RPC (which wrote a caller-supplied cron +// line verbatim into /etc/crontabs/root) has been removed — it allowed a +// delegated user to inject arbitrary root crontab lines via embedded +// newlines (GHSA-ggpf-xrph-wg5v). The schedule now travels as discrete +// auto_update_* fields in the syncCron RPC and is rendered server-side by +// cron_write(), which validates every field before assembling the line. function sync_cron(req) { - let name = req.args?.name || packageName; - let action = req.args?.action; - if (update_cron(action)) + let a = req.args || {}; + // Reject a mismatched package name up front, consistent with the other + // write methods (setInitAction/setRpcdToken). cron_write only ever touches + // the adblock-fast crontab, so a wrong name must be a no-op, not a silent + // operation on adblock-fast. + if ((a.name || packageName) != packageName) + return { result: false }; + // Presence of auto_update_enabled marks an explicit schedule update (from + // the UI). Without it this is a state-only change and cron_write preserves + // the existing line's schedule. Either way cron_write validates. + let schedule = (a.auto_update_enabled != null) ? a : null; + if (cron_write(a.action, schedule)) return { result: true }; return { result: false }; } @@ -422,6 +404,20 @@ const methods = { let name = req.args.name || packageName; let action = req.args.action; if (name != packageName) return { result: false }; + // Allowlist the action up front so the value interpolated into the + // system() calls below can only ever be one of these literals. + const INIT_ACTIONS = { enable: 1, disable: 1, start: 1, stop: 1, + reload: 1, restart: 1, dl: 1, pause: 1 }; + if (!INIT_ACTIONS[action]) return { result: false }; + // dl/start/reload/restart download and process every block-list + // synchronously inside the init script, and 'pause' additionally + // sleeps for pause_timeout. rpcd is single-threaded, so running any + // of these under a blocking system() ties it up — and freezes every + // LuCI page — until the operation finishes (issue #9). Detach these + // so the RPC returns immediately; the UI polls getInitStatus for + // progress. 'stop' is quick and stays synchronous so the UI reflects + // the stopped state right away. + const BG_INIT_ACTIONS = { start: 1, reload: 1, restart: 1, dl: 1, pause: 1 }; if (!access('/etc/init.d/' + packageName, 'x')) return { error: 'Init script not found!' }; @@ -440,14 +436,24 @@ const methods = { break; } case 'start': case 'stop': case 'reload': case 'restart': case 'dl': case 'pause': - if (system(sprintf("/etc/init.d/%s %s >/dev/null 2>&1", packageName, action)) == 0) + if (BG_INIT_ACTIONS[action]) { + // Fire-and-forget: sh backgrounds the job and exits, so + // system() returns at once and the job is reparented to init + // and runs to completion. Report success optimistically — the + // UI tracks the real outcome by polling getInitStatus. + system(sprintf("/etc/init.d/%s %s >/dev/null 2>&1 &", packageName, action)); + result = true; + } else if (system(sprintf("/etc/init.d/%s %s >/dev/null 2>&1", packageName, action)) == 0) { result = true; + } break; } switch (action) { case 'enable': case 'start': case 'disable': case 'stop': - update_cron(action); + // State-only change: preserve the existing schedule line, flip + // its active/suspended/disabled marker. + cron_write(action, null); break; } return { result: result }; @@ -455,18 +461,24 @@ const methods = { }, getCronStatus: { args: { name: 'name' }, - call: get_cron_status, + call: cron_read, }, getCronEntry: { args: { name: 'name' }, call: get_cron_entry, }, - setCronEntry: { - args: { name: 'name', entry: 'entry' }, - call: set_cron_entry, - }, syncCron: { - args: { name: 'name', action: 'action' }, + args: { + name: 'name', action: 'action', + auto_update_enabled: 'auto_update_enabled', + auto_update_mode: 'auto_update_mode', + auto_update_minute: 'auto_update_minute', + auto_update_hour: 'auto_update_hour', + auto_update_weekday: 'auto_update_weekday', + auto_update_monthday: 'auto_update_monthday', + auto_update_every_ndays: 'auto_update_every_ndays', + auto_update_every_nhours: 'auto_update_every_nhours', + }, call: sync_cron, }, setRpcdToken: { @@ -476,6 +488,11 @@ const methods = { let token = req.args.token; if (name != packageName || !token || token == '') return { result: false }; + // Token becomes the adblock-fast-api system password; constrain it + // to an alphanumeric charset so nothing can perturb the passwd + // pipeline (defence in depth — the value is already shell-quoted). + if (!match('' + token, /^[A-Za-z0-9]+$/)) + return { result: false }; // Update UCI config let uci_ctx = cursor();