luci-app-adblock-fast: update to 1.2.4-2
authorStan Grishin <redacted>
Mon, 15 Jun 2026 01:45:37 +0000 (01:45 +0000)
committerStan Grishin <redacted>
Mon, 15 Jun 2026 18:35:46 +0000 (11:35 -0700)
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 <redacted>
applications/luci-app-adblock-fast/Makefile
applications/luci-app-adblock-fast/README.md
applications/luci-app-adblock-fast/htdocs/luci-static/resources/adblock-fast/status.js
applications/luci-app-adblock-fast/htdocs/luci-static/resources/view/adblock-fast/overview.js
applications/luci-app-adblock-fast/root/etc/uci-defaults/40_luci-adblock-fast
applications/luci-app-adblock-fast/root/usr/share/rpcd/acl.d/luci-app-adblock-fast.json
applications/luci-app-adblock-fast/root/usr/share/rpcd/ucode/luci.adblock-fast

index fd596281d8ace0077f4e0bd9ff11c048df09a01f..4318feb735dd967d4a75a687119b040f8a3cdec9 100644 (file)
@@ -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 <stangri@melmac.ca>
-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/
index 44ef1f871028534519e55326862d41711b55673e..5044912c5c061b8ecdc29f5ce0199e247a402f2b 100644 (file)
@@ -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/)
index 1bc6cee6cf6f276543e6105173b2626d4cf10457..71003aac9603d0b41cecc1cbeeb8034c9814744b 100644 (file)
@@ -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,
index fcc5768e40364cbb2edb88fd55defd29682eaea8..1fcb782911116e56494484907de8a4145679dd04 100644 (file)
 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(
+                                                       '<a href="' + pkg.URL + '#chrome-extension" target="_blank">',
+                                                       "</a>"
+                                               )
+                                       ),
+                                       "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.")),
+                                               );
+                               });
+                       });
        },
 });
index 080086891a6e00781a0e1f009650ab9a2a601e7e..ef86c35544079fa80b29691780a5aa9b1ea23283 100644 (file)
@@ -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
index 211623d83487a97b035f554f83b6e10ce9856eaf..ffcb65b5e89cd82acf341f7aa1d333e7b1d4b8b4 100644 (file)
@@ -40,7 +40,6 @@
                        "ubus": {
                                "luci.adblock-fast": [
                                        "syncCron",
-                                       "setCronEntry",
                                        "setInitAction",
                                        "setRpcdToken",
                                        "setQueryLog"
index 470c149d469dbee81459df79f7a98e24f80783b5..f7062814454328a5dd0bdbe7d7e724c410c95ae3 100644 (file)
@@ -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();
git clone https://git.99rst.org/PROJECT